diff --git a/.gitignore b/.gitignore
index aaf66fc36..086c8da3f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -136,4 +136,8 @@ pyhealth/medcode/pretrained_embeddings/kg_emb/examples/pretrained_model
data/physionet.org/
# VSCode settings
-.vscode/
\ No newline at end of file
+.vscode/
+.codex
+
+# Model weight files (large binaries, distributed separately)
+weightfiles/
\ No newline at end of file
diff --git a/ACCEPTANCE_CRITERIA.md b/ACCEPTANCE_CRITERIA.md
new file mode 100644
index 000000000..8e1e45c56
--- /dev/null
+++ b/ACCEPTANCE_CRITERIA.md
@@ -0,0 +1,71 @@
+# PyHealth 2.0 Hardening — Acceptance Criteria
+
+These criteria were drafted by three independent reviews (test/CI health,
+architecture consistency, docs ↔ code alignment) and reconciled into the
+machine-checkable list below. `tools/check_acceptance.sh` runs every check
+and prints PASS/FAIL per criterion; the hardening pass is complete when it
+exits 0.
+
+Run with the environment's interpreter, from the repo root:
+
+```bash
+PYTHON=/path/to/venv/bin/python tools/check_acceptance.sh # full (~10 min)
+PYTHON=/path/to/venv/bin/python tools/check_acceptance.sh --fast # skip suites
+```
+
+## Criteria
+
+| # | Criterion | Verified by |
+|-----|-----------|-------------|
+| C1 | `tests/core` exits 0 — no failures, no errors (skips allowed) | `unittest discover -t . -s tests/core` |
+| C2 | Every skip reason is on the documented allowlist (optional deps, CUDA, Hugging Face Hub reachability, fixture limitations) | skip-reason scan of the C1 log |
+| C3 | `tests/nlp` exits 0 | `unittest discover -t . -s tests/nlp` |
+| C4 | Architecture contracts hold: every `pyhealth` submodule imports cleanly (no import-time env/network requirements) and every concrete `BaseTask` subclass defines `task_name`, `input_schema`, `output_schema` | `tools/check_task_contracts.py` |
+| C5 | The documented 5-stage quickstart pipeline (`docs/api/overview.rst`) runs end-to-end on the bundled offline fixture `test-resources/core/mimic4demo`: dataset → task → `set_task` → dataloader → model → `Trainer.train`/`evaluate` | `tools/check_quickstart.py` |
+| C6 | All imports shown in the docs quickstarts resolve | inline import check in the script |
+| C7 | No stale third-party references in `docs/` (the *combo* library, `yzhao062/*`, `github.com/ycq091044/*` fork links) | grep |
+| C8 | Tutorial videos recorded against PyHealth 1.x are labeled "1.x legacy" rather than presented as current | grep |
+| C9 | No environment-specific `/srv/local` example paths in `pyhealth/` source or docstrings | grep |
+| C10 | `docs/log.rst` development log covers the 2.0 release | grep |
+
+Notes on intentional scope:
+
+- External link liveness and Hugging Face-dependent tests cannot be
+ validated in a network-restricted environment. HF-dependent tests are
+ guarded by `tests.base.hf_hub_accessible()` so they still run in CI,
+ which has internet access.
+- `tests.core.test_sdoh.TestSdoh.test_predict` additionally depends on the
+ gated `meta-llama/Llama-3.1-8B-Instruct` model; even in CI it passes only
+ via its existing gated-repo exemption.
+
+## Architecture audit — findings and disposition
+
+Fixed in this pass:
+
+- `pyhealth/medcode/pretrained_embeddings/lm_emb/openai_retriever.py`
+ raised `KeyError: OPENAI_API_KEY` at **import time**; the key is now read
+ lazily inside `embedding_retrieve` (C4 enforces this class of bug).
+- 49 hardcoded `/srv/local/...` example paths in docstrings and demo blocks
+ across `pyhealth/{tasks,datasets,models,calib}` replaced with neutral
+ `/path/to/data/...` paths (C9).
+- Hugging Face download errors in `tests/core/test_sdoh.py` and
+ `tests/core/test_transformer_deid.py` converted to connectivity-guarded
+ skips via `tests.base.hf_hub_accessible()` (C1/C2).
+
+Deferred (documented, out of scope for this pass):
+
+- **Function-based 1.x tasks** (`drug_recommendation.py`,
+ `cardiology_detect.py`, `sleep_staging.py`, `patient_linkage.py` export
+ `*_fn` functions) coexist with class-based `BaseTask` tasks. Migrating
+ them is an API change that needs its own deprecation cycle.
+- **Generator models** (`pyhealth/models/generators/*`: GPT2, HALO, MedGAN,
+ CorGAN) do not return the standard `{"loss", "y_prob", "y_true",
+ "logit"}` forward contract; they generate synthetic records instead.
+ Deliberate specialization — should be documented in the models API page.
+- **`SdohClassifier`** (`pyhealth/models/sdoh.py`) is an LLM-wrapper
+ exposing `predict()` only (no `forward()`), so it is not `Trainer`
+ compatible. Deliberate; same documentation note applies.
+- **Module-role clarity**: `pyhealth/sampler` (single GraphSAGE sampler),
+ `pyhealth/tokenizer.py` (1.x utility still used by `tfm_tokenizer`), and
+ `pyhealth/nlp` (metrics only) sit outside the documented 5-stage
+ pipeline. Candidates for consolidation in a future minor release.
diff --git a/README.rst b/README.rst
index cf12b5874..32bfc9f35 100644
--- a/README.rst
+++ b/README.rst
@@ -3,7 +3,7 @@ Welcome to PyHealth!
.. note::
- **This README may be out of date.** For the most up-to-date documentation, tutorials, and API reference, please visit our official documentation site at `pyhealth.readthedocs.io `_.
+ **This README covers PyHealth 2.0.** For the full documentation, tutorials, and API reference, please visit our official documentation site at `pyhealth.readthedocs.io `_.
.. important::
@@ -189,8 +189,6 @@ Module 1:
root="https://storage.googleapis.com/pyhealth/Synthetic_MIMIC-III/",
# raw CSV table name
tables=["DIAGNOSES_ICD", "PROCEDURES_ICD", "PRESCRIPTIONS"],
- # map all NDC codes to CCS codes in these tables
- code_mapping={"NDC": "CCSCM"},
)
.. image:: figure/structured-dataset.png
@@ -356,23 +354,23 @@ Module 5:
We provide the following tutorials to help users get started with our pyhealth. Please bear with us as we update the documentation on how to use PyHealth 2.0.
-`Tutorial 0: Introduction to pyhealth.data `_ `[Video] `__
+`Tutorial 0: Introduction to pyhealth.data `_ `[Video] `__
-`Tutorial 1: Introduction to pyhealth.datasets `_ `[Video (PyHealth 1.16)] `__
+`Tutorial 1: Introduction to pyhealth.datasets `_ `[Video (PyHealth 1.16)] `__
-`Tutorial 2: Introduction to pyhealth.tasks `_ `[Video (PyHealth 1.16)] `__
+`Tutorial 2: Introduction to pyhealth.tasks `_ `[Video (PyHealth 1.16)] `__
-`Tutorial 3: Introduction to pyhealth.models `_ `[Video] `__
+`Tutorial 3: Introduction to pyhealth.models `_ `[Video] `__
-`Tutorial 4: Introduction to pyhealth.trainer `_ `[Video] `__
+`Tutorial 4: Introduction to pyhealth.trainer `_ `[Video] `__
-`Tutorial 5: Introduction to pyhealth.metrics `_ `[Video] `__
+`Tutorial 5: Introduction to pyhealth.metrics `_ `[Video] `__
-`Tutorial 6: Introduction to pyhealth.tokenizer `_ `[Video] `__
+`Tutorial 6: Introduction to pyhealth.tokenizer `_ `[Video] `__
-`Tutorial 7: Introduction to pyhealth.medcode `_ `[Video] `__
+`Tutorial 7: Introduction to pyhealth.medcode `_ `[Video] `__
The following tutorials will help users build their own task pipelines.
diff --git a/chat-assistant/corpus/pyhealth-code.txt b/chat-assistant/corpus/pyhealth-code.txt
index 5af6889c0..0ecef087d 100644
--- a/chat-assistant/corpus/pyhealth-code.txt
+++ b/chat-assistant/corpus/pyhealth-code.txt
@@ -26786,7 +26786,7 @@ Here is the code content for tutorial_5_pyhealth_metrics.py:
Automatically generated by Colaboratory.
Original file is located at
- https://colab.research.google.com/drive/1Mrs77EJ92HwMgDaElJ_CBXbi4iABZBeo
+ https://colab.research.google.com/drive/1bO0h5BR62_kQ7zFOgzQmt5vb8jqJ0rV-?usp=drive_link
### **Preparation**
- install pyhealth alpha version
@@ -27611,7 +27611,7 @@ Here is the code content for tutorial_0_pyhealth_data.py:
Automatically generated by Colaboratory.
Original file is located at
- https://colab.research.google.com/drive/1y9PawgSbyMbSSMw1dpfwtooH7qzOEYdN
+ https://colab.research.google.com/drive/17nOzjIjKiAbC8bsntZ3h9xy2Vq4bKpuv
"""
!pip install pyhealth
@@ -28478,7 +28478,7 @@ test_loader = get_dataloader(test_ds, batch_size=64, shuffle=False)
"""### **Step 2: Select a ML model**
- In this tutorial, we use Transformer as the example.
-- please check the [Tutorial 2](https://colab.research.google.com/drive/1LcXZlu7ZUuqepf269X3FhXuhHeRvaJX5?usp=sharing) for more instructions on how to initialize a model.
+- please check the [Tutorial 2](https://colab.research.google.com/drive/1cUTSfFL1wLUXDBtJGTAWntolvcmxrDGo?usp=drive_link) for more instructions on how to initialize a model.
"""
from pyhealth.models import Transformer
@@ -28532,7 +28532,7 @@ Here is the code content for tutorial_6_pyhealth_tokenizer.py:
Automatically generated by Colaboratory.
Original file is located at
- https://colab.research.google.com/drive/1bDOb0A5g0umBjtz8NIp4wqye7taJ03D0
+ https://colab.research.google.com/drive/1jhJ11MLUafhflQAz8HSrWiOEYlIhhvc_
### **Preparation**
- install pyhealth alpha version
@@ -29093,7 +29093,7 @@ Here is the code content for tutorial_7_pyhealth_medcode.py:
Automatically generated by Colaboratory.
Original file is located at
- https://colab.research.google.com/drive/1xrp_ACM2_Hg5Wxzj0SKKKgZfMY0WwEj3
+ https://colab.research.google.com/drive/1Tw1AUS53fotH1EYr4Abp7qYN3zDBeUbC?usp=drive_link
### **Preparation**
- install pyhealth alpha version
@@ -29625,7 +29625,7 @@ Here is the code content for tutorial_3_pyhealth_models.py:
Automatically generated by Colaboratory.
Original file is located at
- https://colab.research.google.com/drive/1LcXZlu7ZUuqepf269X3FhXuhHeRvaJX5
+ https://colab.research.google.com/drive/1cUTSfFL1wLUXDBtJGTAWntolvcmxrDGo?usp=drive_link
### **Preparation**
- install pyhealth alpha version
@@ -30421,7 +30421,7 @@ Here is the code content for tutorial_4_pyhealth_trainer.py:
Automatically generated by Colaboratory.
Original file is located at
- https://colab.research.google.com/drive/1L1Nz76cRNB7wTp5Pz_4Vp4N2eRZ9R6xl
+ https://colab.research.google.com/drive/1up_SL0BxxHPO9pmjKQ98w1GbpiB7LySp?usp=drive_link
### **Preparation**
- install pyhealth alpha version
@@ -30457,7 +30457,7 @@ To initialize a trainer instance, the following environments should be specified
- `load_best_model_at_last`: whether to load the best model during the last iteration.
### **Step 1 & 2 & 3: Prepare datasets, task, and model**
-- Example: We use **MIMIC-III dataset** and **RETAIN** model for **readmission prediction** task. Refer to [Tutorial 1](https://colab.research.google.com/drive/18kbzEQAj1FMs_J9rTGX8eCoxnWdx4Ltn?usp=sharing), [Tutorial 2](https://colab.research.google.com/drive/1r7MYQR_5yCJGpK_9I9-A10HmpupZuIN-?usp=sharing), and [Tutorial 3](https://colab.research.google.com/drive/1LcXZlu7ZUuqepf269X3FhXuhHeRvaJX5?usp=sharing).
+- Example: We use **MIMIC-III dataset** and **RETAIN** model for **readmission prediction** task. Refer to [Tutorial 1](https://colab.research.google.com/drive/18kbzEQAj1FMs_J9rTGX8eCoxnWdx4Ltn?usp=sharing), [Tutorial 2](https://colab.research.google.com/drive/1r7MYQR_5yCJGpK_9I9-A10HmpupZuIN-?usp=sharing), and [Tutorial 3](https://colab.research.google.com/drive/1cUTSfFL1wLUXDBtJGTAWntolvcmxrDGo?usp=drive_link).
"""
# load dataset
diff --git a/chat-assistant/corpus/pyhealth-text.txt b/chat-assistant/corpus/pyhealth-text.txt
index 5d0d59992..705fed027 100644
--- a/chat-assistant/corpus/pyhealth-text.txt
+++ b/chat-assistant/corpus/pyhealth-text.txt
@@ -326,23 +326,23 @@ Module 5:
We provide the following tutorials to help users get started with our pyhealth.
-`Tutorial 0: Introduction to pyhealth.data `_ `[Video] `__
+`Tutorial 0: Introduction to pyhealth.data `_ `[Video] `__
`Tutorial 1: Introduction to pyhealth.datasets `_ `[Video] `__
`Tutorial 2: Introduction to pyhealth.tasks `_ `[Video] `__
-`Tutorial 3: Introduction to pyhealth.models `_ `[Video] `__
+`Tutorial 3: Introduction to pyhealth.models `_ `[Video] `__
-`Tutorial 4: Introduction to pyhealth.trainer `_ `[Video] `__
+`Tutorial 4: Introduction to pyhealth.trainer `_ `[Video] `__
-`Tutorial 5: Introduction to pyhealth.metrics `_ `[Video] `__
+`Tutorial 5: Introduction to pyhealth.metrics `_ `[Video] `__
-`Tutorial 6: Introduction to pyhealth.tokenizer `_ `[Video] `__
+`Tutorial 6: Introduction to pyhealth.tokenizer `_ `[Video] `__
-`Tutorial 7: Introduction to pyhealth.medcode `_ `[Video] `__
+`Tutorial 7: Introduction to pyhealth.medcode `_ `[Video] `__
The following tutorials will help users build their own task pipelines.
@@ -1206,21 +1206,21 @@ Tutorials
We provide the following tutorials to help users get started with our pyhealth.
-`Tutorial 0: Introduction to pyhealth.data `_ `[Video] `_
+`Tutorial 0: Introduction to pyhealth.data `_ `[Video] `_
`Tutorial 1: Introduction to pyhealth.datasets `_ `[Video] `_
`Tutorial 2: Introduction to pyhealth.tasks `_ `[Video] `_
-`Tutorial 3: Introduction to pyhealth.models `_ `[Video] `_
+`Tutorial 3: Introduction to pyhealth.models `_ `[Video] `_
-`Tutorial 4: Introduction to pyhealth.trainer `_ `[Video] `_
+`Tutorial 4: Introduction to pyhealth.trainer `_ `[Video] `_
-`Tutorial 5: Introduction to pyhealth.metrics `_ `[Video] `_
+`Tutorial 5: Introduction to pyhealth.metrics `_ `[Video] `_
-`Tutorial 6: Introduction to pyhealth.tokenizer `_ `[Video] `_
+`Tutorial 6: Introduction to pyhealth.tokenizer `_ `[Video] `_
-`Tutorial 7: Introduction to pyhealth.medcode `_ `[Video] `_
+`Tutorial 7: Introduction to pyhealth.medcode `_ `[Video] `_
The following tutorials will help users build their own task pipelines. `[Video] `_
diff --git a/chat-assistant/corpus/pyhealth.txt b/chat-assistant/corpus/pyhealth.txt
index 10cd5456e..309f791de 100644
--- a/chat-assistant/corpus/pyhealth.txt
+++ b/chat-assistant/corpus/pyhealth.txt
@@ -326,23 +326,23 @@ Module 5:
We provide the following tutorials to help users get started with our pyhealth.
-`Tutorial 0: Introduction to pyhealth.data `_ `[Video] `__
+`Tutorial 0: Introduction to pyhealth.data `_ `[Video] `__
`Tutorial 1: Introduction to pyhealth.datasets `_ `[Video] `__
`Tutorial 2: Introduction to pyhealth.tasks `_ `[Video] `__
-`Tutorial 3: Introduction to pyhealth.models `_ `[Video] `__
+`Tutorial 3: Introduction to pyhealth.models `_ `[Video] `__
-`Tutorial 4: Introduction to pyhealth.trainer `_ `[Video] `__
+`Tutorial 4: Introduction to pyhealth.trainer `_ `[Video] `__
-`Tutorial 5: Introduction to pyhealth.metrics `_ `[Video] `__
+`Tutorial 5: Introduction to pyhealth.metrics `_ `[Video] `__
-`Tutorial 6: Introduction to pyhealth.tokenizer `_ `[Video] `__
+`Tutorial 6: Introduction to pyhealth.tokenizer `_ `[Video] `__
-`Tutorial 7: Introduction to pyhealth.medcode `_ `[Video] `__
+`Tutorial 7: Introduction to pyhealth.medcode `_ `[Video] `__
The following tutorials will help users build their own task pipelines.
@@ -1206,21 +1206,21 @@ Tutorials
We provide the following tutorials to help users get started with our pyhealth.
-`Tutorial 0: Introduction to pyhealth.data `_ `[Video] `_
+`Tutorial 0: Introduction to pyhealth.data `_ `[Video] `_
`Tutorial 1: Introduction to pyhealth.datasets `_ `[Video] `_
`Tutorial 2: Introduction to pyhealth.tasks `_ `[Video] `_
-`Tutorial 3: Introduction to pyhealth.models `_ `[Video] `_
+`Tutorial 3: Introduction to pyhealth.models `_ `[Video] `_
-`Tutorial 4: Introduction to pyhealth.trainer `_ `[Video] `_
+`Tutorial 4: Introduction to pyhealth.trainer `_ `[Video] `_
-`Tutorial 5: Introduction to pyhealth.metrics `_ `[Video] `_
+`Tutorial 5: Introduction to pyhealth.metrics `_ `[Video] `_
-`Tutorial 6: Introduction to pyhealth.tokenizer `_ `[Video] `_
+`Tutorial 6: Introduction to pyhealth.tokenizer `_ `[Video] `_
-`Tutorial 7: Introduction to pyhealth.medcode `_ `[Video] `_
+`Tutorial 7: Introduction to pyhealth.medcode `_ `[Video] `_
The following tutorials will help users build their own task pipelines. `[Video] `_
@@ -28027,7 +28027,7 @@ Here is the code content for tutorial_5_pyhealth_metrics.py:
Automatically generated by Colaboratory.
Original file is located at
- https://colab.research.google.com/drive/1Mrs77EJ92HwMgDaElJ_CBXbi4iABZBeo
+ https://colab.research.google.com/drive/1bO0h5BR62_kQ7zFOgzQmt5vb8jqJ0rV-?usp=drive_link
### **Preparation**
- install pyhealth alpha version
@@ -28852,7 +28852,7 @@ Here is the code content for tutorial_0_pyhealth_data.py:
Automatically generated by Colaboratory.
Original file is located at
- https://colab.research.google.com/drive/1y9PawgSbyMbSSMw1dpfwtooH7qzOEYdN
+ https://colab.research.google.com/drive/17nOzjIjKiAbC8bsntZ3h9xy2Vq4bKpuv
"""
!pip install pyhealth
@@ -29719,7 +29719,7 @@ test_loader = get_dataloader(test_ds, batch_size=64, shuffle=False)
"""### **Step 2: Select a ML model**
- In this tutorial, we use Transformer as the example.
-- please check the [Tutorial 2](https://colab.research.google.com/drive/1LcXZlu7ZUuqepf269X3FhXuhHeRvaJX5?usp=sharing) for more instructions on how to initialize a model.
+- please check the [Tutorial 2](https://colab.research.google.com/drive/1cUTSfFL1wLUXDBtJGTAWntolvcmxrDGo?usp=drive_link) for more instructions on how to initialize a model.
"""
from pyhealth.models import Transformer
@@ -29773,7 +29773,7 @@ Here is the code content for tutorial_6_pyhealth_tokenizer.py:
Automatically generated by Colaboratory.
Original file is located at
- https://colab.research.google.com/drive/1bDOb0A5g0umBjtz8NIp4wqye7taJ03D0
+ https://colab.research.google.com/drive/1jhJ11MLUafhflQAz8HSrWiOEYlIhhvc_
### **Preparation**
- install pyhealth alpha version
@@ -30334,7 +30334,7 @@ Here is the code content for tutorial_7_pyhealth_medcode.py:
Automatically generated by Colaboratory.
Original file is located at
- https://colab.research.google.com/drive/1xrp_ACM2_Hg5Wxzj0SKKKgZfMY0WwEj3
+ https://colab.research.google.com/drive/1Tw1AUS53fotH1EYr4Abp7qYN3zDBeUbC?usp=drive_link
### **Preparation**
- install pyhealth alpha version
@@ -30866,7 +30866,7 @@ Here is the code content for tutorial_3_pyhealth_models.py:
Automatically generated by Colaboratory.
Original file is located at
- https://colab.research.google.com/drive/1LcXZlu7ZUuqepf269X3FhXuhHeRvaJX5
+ https://colab.research.google.com/drive/1cUTSfFL1wLUXDBtJGTAWntolvcmxrDGo?usp=drive_link
### **Preparation**
- install pyhealth alpha version
@@ -31662,7 +31662,7 @@ Here is the code content for tutorial_4_pyhealth_trainer.py:
Automatically generated by Colaboratory.
Original file is located at
- https://colab.research.google.com/drive/1L1Nz76cRNB7wTp5Pz_4Vp4N2eRZ9R6xl
+ https://colab.research.google.com/drive/1up_SL0BxxHPO9pmjKQ98w1GbpiB7LySp?usp=drive_link
### **Preparation**
- install pyhealth alpha version
@@ -31698,7 +31698,7 @@ To initialize a trainer instance, the following environments should be specified
- `load_best_model_at_last`: whether to load the best model during the last iteration.
### **Step 1 & 2 & 3: Prepare datasets, task, and model**
-- Example: We use **MIMIC-III dataset** and **RETAIN** model for **readmission prediction** task. Refer to [Tutorial 1](https://colab.research.google.com/drive/18kbzEQAj1FMs_J9rTGX8eCoxnWdx4Ltn?usp=sharing), [Tutorial 2](https://colab.research.google.com/drive/1r7MYQR_5yCJGpK_9I9-A10HmpupZuIN-?usp=sharing), and [Tutorial 3](https://colab.research.google.com/drive/1LcXZlu7ZUuqepf269X3FhXuhHeRvaJX5?usp=sharing).
+- Example: We use **MIMIC-III dataset** and **RETAIN** model for **readmission prediction** task. Refer to [Tutorial 1](https://colab.research.google.com/drive/18kbzEQAj1FMs_J9rTGX8eCoxnWdx4Ltn?usp=sharing), [Tutorial 2](https://colab.research.google.com/drive/1r7MYQR_5yCJGpK_9I9-A10HmpupZuIN-?usp=sharing), and [Tutorial 3](https://colab.research.google.com/drive/1cUTSfFL1wLUXDBtJGTAWntolvcmxrDGo?usp=drive_link).
"""
# load dataset
diff --git a/docs/_static/external_links.js b/docs/_static/external_links.js
new file mode 100644
index 000000000..1cc5cb605
--- /dev/null
+++ b/docs/_static/external_links.js
@@ -0,0 +1,9 @@
+// Open every external link (http/https, different host) in a new tab.
+document.addEventListener("DOMContentLoaded", () => {
+ for (const a of document.querySelectorAll('a[href^="http"]')) {
+ if (!a.href.includes(window.location.host)) {
+ a.target = "_blank";
+ a.rel = "noopener noreferrer";
+ }
+ }
+});
diff --git a/docs/about.rst b/docs/about.rst
index 8e28fa929..d758eaf0b 100644
--- a/docs/about.rst
+++ b/docs/about.rst
@@ -3,7 +3,7 @@ About us
PyHealth is developed and maintained by a diverse community of researchers and practitioners.
-Current Maintainers
+Maintainers
------------------
`Zhenbang Wu `_ (Ph.D. Student @ University of Illinois Urbana-Champaign)
@@ -12,11 +12,11 @@ Current Maintainers
`Junyi Gao `_ (M.S. @ UIUC, Ph.D. Student @ University of Edinburgh)
-Paul Landes (University of Illinois College of Medicine)
+`Paul Landes `_ (University of Illinois College of Medicine)
`Jimeng Sun `_ (Professor @ University of Illinois Urbana-Champaign)
-Major Reviewers
+Reviewers
---------------
Eric Schrock (University of Illinois Urbana-Champaign)
@@ -52,7 +52,7 @@ Muni Bondu
*...and more members as the initiative continues to expand*
-Alumni
+Past Contributors
------
`Chaoqi Yang `_ (Ph.D. Student @ University of Illinois Urbana-Champaign)
diff --git a/docs/api/data.rst b/docs/api/data.rst
index c6e940a68..411d4857a 100644
--- a/docs/api/data.rst
+++ b/docs/api/data.rst
@@ -8,7 +8,7 @@ Getting Started
New to PyHealth's data structures? Start here:
-- **Tutorial**: `Introduction to pyhealth.data `_ | `Video `_
+- **Tutorial**: `Introduction to pyhealth.data `_ | `Video `_
This tutorial introduces the core data structures in PyHealth:
diff --git a/docs/api/datasets.rst b/docs/api/datasets.rst
index 3412e5ac5..0eea1d45d 100644
--- a/docs/api/datasets.rst
+++ b/docs/api/datasets.rst
@@ -6,11 +6,11 @@ Getting Started
New to PyHealth datasets? Start here:
-- **Tutorial**: `Introduction to pyhealth.datasets `_ | `Video (PyHealth 1.6) `_
+- **Tutorial**: `Introduction to pyhealth.datasets `_ | `Video (PyHealth 1.x legacy) `_
This tutorial covers:
-- How to load and work with different healthcare datasets (MIMIC-III, MIMIC-IV, eICU, etc.)
+- How to load and work with any PyHealth dataset (MIMIC-III, MIMIC-IV, eICU, OMOP, and many more)
- Understanding the ``BaseDataset`` structure and patient representation
- Parsing raw EHR data into standardized PyHealth format
- Accessing patient records, visits, and clinical events
@@ -22,6 +22,198 @@ This tutorial covers:
- Using openly available demo datasets (MIMIC-III Demo, MIMIC-IV Demo)
- Working with synthetic data for testing
+How PyHealth Loads Data
+------------------------
+
+When you initialise a dataset, PyHealth reads the raw CSV or Parquet files
+using Polars, joins the tables according to a YAML schema, and writes a
+compact ``global_event_df.parquet`` cache to disk. On subsequent runs with
+the same configuration it reads from cache rather than re-parsing the source
+files, so startup is fast.
+
+The result is a :class:`~pyhealth.datasets.BaseDataset` — a structured
+patient→event tree. It is different from a PyTorch Dataset: it has no integer
+length and you cannot index into it with ``dataset[i]``. Think of it as a
+queryable dictionary of patient records. To turn it into something a model
+can train on, you call ``dataset.set_task()`` (see :doc:`tasks`), which
+returns a :class:`~pyhealth.datasets.SampleDataset` that *is* indexable and
+DataLoader-ready.
+
+From BaseDataset to SampleDataset
+-----------------------------------
+
+``BaseDataset`` and ``SampleDataset`` serve different roles and are not
+interchangeable:
+
+- **BaseDataset** is a queryable patient registry. It holds the raw
+ patient→visit→event tree loaded from disk. You cannot index into it like a
+ list — it has no integer length and is not DataLoader-ready.
+- **SampleDataset** is a PyTorch-compatible streaming dataset returned by
+ ``dataset.set_task()``. Each element is a fully processed feature
+ dictionary that a model can consume directly.
+
+The conversion happens in one call:
+
+.. code-block:: python
+
+ import torch
+ from pyhealth.datasets import MIMIC3Dataset
+ from pyhealth.tasks import MortalityPredictionMIMIC3
+
+ dataset = MIMIC3Dataset(root="...", tables=["diagnoses_icd"])
+ samples = dataset.set_task(MortalityPredictionMIMIC3())
+ # `samples` is a SampleDataset — pass it straight to a DataLoader
+ loader = torch.utils.data.DataLoader(samples, batch_size=32)
+
+Under the hood, ``set_task()`` runs a ``SampleBuilder`` that fits feature
+processors (tokenisers, label encoders, etc.) across the full dataset, then
+writes compressed, chunked sample files to disk via
+`litdata `_. A companion
+``schema.pkl`` stores the fitted processors so the dataset can be reloaded in
+future runs without re-fitting.
+
+``SampleDataset`` also exposes two convenience lookups built during fitting:
+
+- ``samples.patient_to_index`` — maps a patient ID to all sample indices for
+ that patient.
+- ``samples.record_to_index`` — maps a visit/record ID to the sample indices
+ for that visit.
+
+For testing or small cohorts you can skip the disk step entirely using
+``InMemorySampleDataset``, which holds all processed samples in RAM and is
+returned by default from ``create_sample_dataset()``.
+
+.. note::
+ Building a custom dataset or bringing your own data?
+ See :doc:`../tutorials` (Tutorial 1) for a step-by-step walkthrough, and
+ the `config.yaml for Custom Datasets`_ section below for the schema format.
+
+Native Datasets vs Custom Datasets
+------------------------------------
+
+PyHealth includes native support for many standard healthcare databases — including
+MIMIC-III, MIMIC-IV, eICU, OMOP, and many others (see the full list in `Available Datasets`_
+below). All of these come with built-in schema definitions so you
+can load them with just a root path and a list of tables:
+
+.. code-block:: python
+
+ from pyhealth.datasets import MIMIC3Dataset
+
+ if __name__ == '__main__':
+ dataset = MIMIC3Dataset(
+ root="/data/physionet.org/files/mimiciii/1.4",
+ tables=["diagnoses_icd", "procedures_icd", "prescriptions"],
+ cache_dir=".cache",
+ dev=True, # use 1 000 patients while exploring
+ )
+
+For any other data source — a custom patient registry, an institutional cohort,
+or a non-EHR dataset — you create a subclass of ``BaseDataset`` and provide a
+``config.yaml`` file that describes your table structure.
+
+Initialization Parameters
+--------------------------
+
+- **root** — path to the directory containing the raw data files. For MIMIC-IV
+ specifically, use ``ehr_root`` instead of ``root``.
+- **tables** — the table names you want to load, e.g.
+ ``["diagnoses_icd", "labevents"]``. Only these tables will be accessible in
+ patient queries downstream.
+- **config_path** — path to your ``config.yaml``; needed for custom datasets.
+ Native datasets have this built in and ignore the parameter.
+- **cache_dir** — where to store the cached Parquet and LitData files. PyHealth
+ appends a UUID derived from your configuration, so different setups never
+ overwrite each other.
+- **num_workers** — parallel processes for data loading. Increasing this can
+ speed up ``set_task()`` on large datasets.
+- **dev** — when ``True``, PyHealth caps the dataset at 1 000 patients. This
+ is very useful during development because it makes each iteration complete in
+ seconds rather than minutes. Switch to ``dev=False`` for your final training
+ run.
+
+config.yaml for Custom Datasets
+---------------------------------
+
+If you are bringing your own data, the YAML file tells PyHealth which column
+is the patient identifier, which column is the timestamp, and which other
+columns to include as event attributes:
+
+.. code-block:: yaml
+
+ tables:
+ my_table:
+ file_path: relative/path/to/file.csv
+ patient_id: subject_id
+ timestamp: charttime
+ timestamp_format: "%Y-%m-%d %H:%M:%S"
+ attributes:
+ - icd_code
+ - value
+ - itemid
+ join: [] # optional table joins
+
+All attribute column names are lowercased internally, so ``ICD_CODE`` in
+your CSV becomes ``icd_code`` in your code.
+
+Querying Patients and Events
+-----------------------------
+
+Once a dataset is loaded, you can explore it using these methods:
+
+.. code-block:: python
+
+ dataset.unique_patient_ids # all patient IDs as a list of strings
+ dataset.get_patient("p001") # retrieve one Patient object
+ dataset.iter_patients() # iterate over all patients
+ dataset.stats() # print patient and event counts
+
+Patient records are accessed through ``get_events()``, which supports
+temporal filtering and attribute-level filters:
+
+.. code-block:: python
+
+ events = patient.get_events(
+ event_type="diagnoses_icd", # table name from your config
+ start=datetime(2020, 1, 1), # optional: exclude earlier events
+ end=datetime(2020, 6, 1), # optional: exclude later events
+ filters=[("icd_code", "==", "250.00")], # optional: attribute conditions
+ )
+
+Each event in the returned list has:
+
+- ``event.timestamp`` — a Python ``datetime`` object. PyHealth normalises all
+ timestamp columns (``charttime``, ``admittime``, etc.) into this single
+ property, so this is what you should use regardless of what the original
+ column was called.
+- ``event.icd_code``, ``event["icd_code"]``, ``event.attr_dict`` — different
+ ways to access the other attributes. All attribute names are lowercase.
+
+Things to Watch Out For
+------------------------
+
+A few patterns that commonly trip up new users:
+
+**BaseDataset vs SampleDataset.** Models expect a ``SampleDataset`` (the
+output of ``set_task()``), not the raw ``BaseDataset``. Passing the wrong one
+will raise an error. If you see an ``AttributeError`` about ``input_schema``
+or ``output_schema``, this is likely the cause.
+
+**Timestamp attribute names.** Writing ``event.charttime`` will raise an
+``AttributeError`` because PyHealth remaps that column to ``event.timestamp``.
+The same applies to ``admittime``, ``starttime``, or whatever the original
+column was called.
+
+**Column name casing.** PyHealth lowercases all column names at load time.
+Even if your source CSV has ``ICD_CODE``, you access it as ``event.icd_code``.
+
+**dev=True in production.** The ``dev`` flag is great for exploring data but
+it caps the dataset at 1 000 patients. Remember to switch to ``dev=False``
+before running a full training job.
+
+**Multiprocessing guard.** Scripts that call ``set_task()`` should wrap their
+top-level code in ``if __name__ == '__main__':``. See :doc:`tasks` for details.
+
Available Datasets
------------------
@@ -32,6 +224,8 @@ Available Datasets
datasets/pyhealth.datasets.SampleDataset
datasets/pyhealth.datasets.MIMIC3Dataset
datasets/pyhealth.datasets.MIMIC4Dataset
+ datasets/pyhealth.datasets.FHIRDataset
+ datasets/pyhealth.datasets.MIMIC4FHIR
datasets/pyhealth.datasets.MedicalTranscriptionsDataset
datasets/pyhealth.datasets.CardiologyDataset
datasets/pyhealth.datasets.eICUDataset
@@ -46,6 +240,7 @@ Available Datasets
datasets/pyhealth.datasets.BMDHSDataset
datasets/pyhealth.datasets.COVID19CXRDataset
datasets/pyhealth.datasets.ChestXray14Dataset
+ datasets/pyhealth.datasets.PhysioNetDeIDDataset
datasets/pyhealth.datasets.TUABDataset
datasets/pyhealth.datasets.TUEVDataset
datasets/pyhealth.datasets.ClinVarDataset
diff --git a/docs/api/datasets/pyhealth.datasets.FHIRDataset.rst b/docs/api/datasets/pyhealth.datasets.FHIRDataset.rst
new file mode 100644
index 000000000..16dbefc13
--- /dev/null
+++ b/docs/api/datasets/pyhealth.datasets.FHIRDataset.rst
@@ -0,0 +1,306 @@
+pyhealth.datasets.FHIRDataset
+=====================================
+
+A generic, config-driven NDJSON ingest for `HL7 FHIR
+`_ datasets. The whole pipeline is described by **a
+single YAML config** with three top-level sections — what files to read, how to
+turn each FHIR resource into a flat row, and how those rows appear as events
+downstream. A custom FHIR ingest is "point at a YAML" — no Python required.
+
+The bundled :class:`~pyhealth.datasets.MIMIC4FHIR` subclass uses this engine
+with the ``pyhealth/datasets/fhir/configs/mimic4fhir.yaml`` config tuned for
+PhysioNet's MIMIC-IV on FHIR export. See the sub-page below for the quick-start.
+
+.. contents:: On this page
+ :local:
+ :depth: 1
+
+
+Quick start
+-----------
+
+.. code-block:: python
+
+ from pyhealth.datasets import MIMIC4FHIR, get_dataloader, split_by_patient
+ from pyhealth.tasks.mpf_clinical_prediction import MPFClinicalPredictionTask
+ from pyhealth.models import EHRMambaCEHR
+ from pyhealth.trainer import Trainer
+
+ def main():
+ ds = MIMIC4FHIR(root="/data/mimic-iv-fhir")
+ sample_ds = ds.set_task(MPFClinicalPredictionTask(), num_workers=1)
+ train, val, test = split_by_patient(sample_ds, [0.7, 0.1, 0.2])
+ vocab_size = sample_ds.input_processors["concept_ids"].vocab.vocab_size
+ model = EHRMambaCEHR(dataset=sample_ds, vocab_size=vocab_size)
+ Trainer(model=model).train(
+ train_dataloader=get_dataloader(train, batch_size=8, shuffle=True),
+ val_dataloader=get_dataloader(val, batch_size=8),
+ epochs=2,
+ )
+
+ if __name__ == "__main__":
+ main()
+
+(``if __name__ == "__main__":`` matters — :meth:`~pyhealth.datasets.BaseDataset.set_task`
+forks Dask workers; without the guard the workers re-import and re-spawn.)
+
+
+Pipeline at a glance
+--------------------
+
+::
+
+ NDJSON shards on disk
+ |
+ | (Phase A) — stream line by line, route by resourceType,
+ | project via the YAML's resource_specs
+ v
+ flattened_tables/.parquet <- cache #1
+ |
+ | (Phase B) — load_table, dd.concat, sort by patient_id (Dask)
+ v
+ global_event_df.parquet/part-*.parquet <- cache #2
+ |
+ | (Phase C) — task_transform per-patient sample emit
+ v
+ task_df.ld/ <- cache #3a
+ |
+ | fit CehrProcessor vocab via SampleBuilder.fit(dataset)
+ | proc_transform per-sample tensorisation
+ v
+ samples_*.ld/ <- cache #3b ──> SampleDataset
+
+Each of the three cache tiers has its own existence check; re-running with
+identical inputs skips every phase. Cache identity hashes the YAML byte digest,
+glob patterns, ``max_patients``, and engine schema version — any meaningful
+config change invalidates everything below it. See
+:class:`~pyhealth.datasets.BaseDataset` for the Phase B/C internals that are
+shared with all other PyHealth datasets.
+
+
+The unified YAML config
+-----------------------
+
+A FHIR ingest YAML has three top-level sections. The bundled
+``mimic4fhir.yaml`` is the canonical worked example; what follows is the
+section-by-section reference.
+
+Section 1: ``glob_patterns:`` (which files to read)
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. code-block:: yaml
+
+ glob_patterns:
+ - "**/MimicPatient*.ndjson.gz"
+ - "**/MimicEncounter*.ndjson.gz"
+ # ... one pattern per resource-type shard family
+
+Defaults to ``["**/*.ndjson.gz"]`` when omitted. Only worth setting when your
+export has a per-resource-type file-naming convention you want to exploit for
+speed — PhysioNet MIMIC-IV FHIR ships shards as ``MimicPatient*.ndjson.gz``,
+``MimicEncounter*.ndjson.gz``, etc., and filtering at the file level avoids
+decompressing ~10% of the export that contains only unconfigured resource
+types. For a generic export where everything is in ``bundles.ndjson.gz``, omit
+this block and the streamer will filter by ``resourceType`` after parsing.
+
+Override at runtime via ``MIMIC4FHIR(glob_pattern=...)`` or
+``MIMIC4FHIR(glob_patterns=[...])``.
+
+Section 2: ``resource_specs:`` (how to project JSON into rows)
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Keys are FHIR ``resourceType`` strings. For each, declare a ``table`` name and
+an ordered ``columns`` mapping:
+
+.. code-block:: yaml
+
+ resource_specs:
+
+ Patient:
+ table: patient
+ columns:
+ patient_id: { locate: ["id"], required: true }
+ birth_date: { locate: ["birthDate"] }
+ gender: { locate: ["gender"] }
+ deceased_boolean: { locate: ["deceasedBoolean"], transform: bool_norm }
+
+ Observation:
+ table: observation
+ columns:
+ patient_id: { locate: ["subject.reference"], transform: ref_id, required: true }
+ resource_id: { locate: ["id"] }
+ encounter_id: { locate: ["encounter.reference"], transform: ref_id }
+ event_time: { locate: ["effectiveDateTime", "effectivePeriod.start", "issued"] }
+ concept_key: { locate: ["code"], transform: coding_key }
+
+Each column entry has three fields:
+
+``locate`` *(required, list of dotted paths)*
+ Ordered JSON paths into the resource; the first that resolves to a non-null
+ value wins. This is how FHIR choice-types (``onset[x]``, ``effective[x]``,
+ ``performed[x]``, …) are handled — list every variant explicitly. A single
+ string is accepted as shorthand for a one-element list.
+
+``transform`` *(optional, name of a built-in transform, default ``identity``)*
+ Maps the located leaf to a flat scalar string. See the registry below.
+
+``required`` *(optional, bool, default false)*
+ When ``true``, a resource whose ``locate`` cannot be resolved is **dropped**
+ (and logged) rather than emitted with a null. Use this on the patient
+ reference column so events without a discoverable patient never reach the
+ global event frame.
+
+Transform registry
+^^^^^^^^^^^^^^^^^^
+
+Available transforms (defined in
+``pyhealth/datasets/fhir/utils.py`` ``TRANSFORMS`` dict):
+
+================== ===========================================================
+``identity`` Pass the value through. Stringifies non-string scalars.
+``ref_id`` Reference object or ``"Patient/p1"`` -> ``"p1"``.
+``coding_key`` CodeableConcept -> ``"system|code"`` of its first coding.
+``bool_norm`` JSON boolean / ``"true"``/``"false"`` -> ``"true"``/``"false"``/None.
+``med_concept`` MedicationRequest medication[x] -> codeable-concept or
+ ``"MedicationRequest/reference|"`` fallback.
+================== ===========================================================
+
+Adding a new transform is a one-liner: register a callable in ``TRANSFORMS``
+in ``utils.py`` and reference it by name from the YAML.
+
+Section 3: ``tables:`` (how rows are exposed as events)
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Keys here must match the ``table:`` values from Section 2. Each entry tells
+:meth:`~pyhealth.datasets.BaseDataset.load_table` how to read the flat parquet:
+
+.. code-block:: yaml
+
+ tables:
+ patient:
+ file_path: "patient.parquet"
+ patient_id: "patient_id"
+ timestamp: "birth_date"
+ attributes: ["birth_date", "gender", "deceased_boolean"]
+
+ observation:
+ file_path: "observation.parquet"
+ patient_id: "patient_id"
+ timestamp: "event_time"
+ attributes: ["resource_id", "encounter_id", "event_time", "concept_key"]
+
+``file_path`` is the parquet filename inside the cached
+``flattened_tables/`` directory. ``patient_id`` and ``timestamp`` name the
+columns to surface as the normalised ``patient_id`` and ``timestamp`` on each
+event. ``attributes`` is the list of columns surfaced as event attributes — in
+the global event frame they're renamed to ``{table}/{attr}`` and later show up
+on ``patient.get_events(event_type=...).attr_name``.
+
+Cross-section validation
+~~~~~~~~~~~~~~~~~~~~~~~~
+
+At load time the dataset checks that every ``table:`` value declared in
+Section 2 has a matching ``tables.`` block in Section 3. Typos surface
+as a config error at startup, not silent empty parquets.
+
+
+Customising for a non-MIMIC FHIR export
+---------------------------------------
+
+Step 1 — write your YAML.
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Copy ``pyhealth/datasets/fhir/configs/mimic4fhir.yaml`` and adapt the
+``resource_specs:`` and ``tables:`` blocks for the resources you care about.
+For an export that adds Immunizations:
+
+.. code-block:: yaml
+
+ resource_specs:
+ Patient:
+ table: patient
+ columns:
+ patient_id: { locate: ["id"], required: true }
+ birth_date: { locate: ["birthDate"] }
+ Immunization:
+ table: immunization
+ columns:
+ patient_id: { locate: ["patient.reference"], transform: ref_id, required: true }
+ resource_id: { locate: ["id"] }
+ event_time: { locate: ["occurrenceDateTime", "recorded"] }
+ concept_key: { locate: ["vaccineCode"], transform: coding_key }
+
+ tables:
+ patient:
+ file_path: "patient.parquet"
+ patient_id: "patient_id"
+ timestamp: "birth_date"
+ attributes: ["birth_date"]
+ immunization:
+ file_path: "immunization.parquet"
+ patient_id: "patient_id"
+ timestamp: "event_time"
+ attributes: ["resource_id", "event_time", "concept_key"]
+
+Step 2 — instantiate
+~~~~~~~~~~~~~~~~~~~~
+
+Either pass ``config_path=...`` directly:
+
+.. code-block:: python
+
+ from pyhealth.datasets import FHIRDataset
+
+ ds = FHIRDataset(
+ root="/data/my_fhir_export",
+ config_path="/path/to/my_export.yaml",
+ )
+
+or write a 3-line subclass that bundles your config:
+
+.. code-block:: python
+
+ from pyhealth.datasets import FHIRDataset
+
+ class MyFHIR(FHIRDataset):
+ DEFAULT_CONFIG_PATH = "/path/to/my_export.yaml"
+
+ ds = MyFHIR(root="/data/my_fhir_export")
+
+Step 3 — that's it.
+~~~~~~~~~~~~~~~~~~~
+
+Everything downstream — :meth:`~pyhealth.datasets.BaseDataset.set_task`,
+:meth:`~pyhealth.datasets.BaseDataset.iter_patients`,
+:meth:`~pyhealth.datasets.BaseDataset.get_patient` — works the same as for any
+other PyHealth dataset.
+
+
+Notes on resource use
+---------------------
+
+Streaming ingest avoids loading the whole NDJSON corpus into RAM, but downstream
+steps still scale with cohort size. For a **smoke run** the bundled example
+fixtures fit on any laptop. For a **laptop-scale real subset**, set
+``max_patients=`` and/or narrow ``glob_patterns`` to keep cache and task passes
+manageable; ≥16 GB system RAM is a comfort target for Polars + the trainer.
+For the **full PhysioNet export**, prefer fast SSD, large disk, and plenty of
+RAM — total work scales with the corpus size even if RAM ingest is bounded.
+
+
+Bundled FHIR datasets
+---------------------
+
+.. toctree::
+ :maxdepth: 1
+
+ pyhealth.datasets.MIMIC4FHIR
+
+
+API reference
+-------------
+
+.. autoclass:: pyhealth.datasets.FHIRDataset
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/api/datasets/pyhealth.datasets.MIMIC4FHIR.rst b/docs/api/datasets/pyhealth.datasets.MIMIC4FHIR.rst
new file mode 100644
index 000000000..344f60cf7
--- /dev/null
+++ b/docs/api/datasets/pyhealth.datasets.MIMIC4FHIR.rst
@@ -0,0 +1,78 @@
+pyhealth.datasets.MIMIC4FHIR
+============================
+
+A pre-bundled :class:`~pyhealth.datasets.FHIRDataset` for the PhysioNet
+`MIMIC-IV on FHIR `_ export
+(R4, demo 2.1.0 and full release). All ingest logic — file globs, per-resource
+projection, downstream event schema — is described by the bundled YAML at
+``pyhealth/datasets/fhir/configs/mimic4fhir.yaml``; this class only points at
+that path.
+
+For everything outside the MIMIC-specific defaults (transform registry,
+``Col`` / ``ResourceSpec`` syntax, the three-tier cache story), see the parent
+page: :doc:`pyhealth.datasets.FHIRDataset`.
+
+Quick start
+-----------
+
+.. code-block:: python
+
+ from pyhealth.datasets import MIMIC4FHIR
+ from pyhealth.tasks.mpf_clinical_prediction import MPFClinicalPredictionTask
+
+ def main():
+ ds = MIMIC4FHIR(root="/data/mimic-iv-fhir")
+ sample_ds = ds.set_task(MPFClinicalPredictionTask(), num_workers=1)
+ # ... split / dataloader / model / trainer ...
+
+ if __name__ == "__main__":
+ main()
+
+For the full end-to-end demo (training EHR-Mamba on MPF samples) see
+``examples/mimic4fhir_mpf_ehrmamba.py``.
+
+Resource coverage
+-----------------
+
+The bundled config flattens six FHIR resource types out of the PhysioNet
+export:
+
+========================== ============================ ===============================
+FHIR resourceType Output table Key columns
+========================== ============================ ===============================
+``Patient`` ``patient.parquet`` ``patient_id``, ``birth_date``, ``gender``, ``deceased_*``
+``Encounter`` ``encounter.parquet`` ``patient_id``, ``encounter_id``, ``event_time``, ``encounter_class``
+``Condition`` ``condition.parquet`` ``patient_id``, ``encounter_id``, ``event_time``, ``concept_key``
+``Observation`` ``observation.parquet`` ``patient_id``, ``encounter_id``, ``event_time``, ``concept_key``
+``MedicationRequest`` ``medication_request.parquet`` ``patient_id``, ``encounter_id``, ``event_time``, ``concept_key``
+``Procedure`` ``procedure.parquet`` ``patient_id``, ``encounter_id``, ``event_time``, ``concept_key``
+========================== ============================ ===============================
+
+PhysioNet shards that contain only other resource types
+(``MedicationAdministration``, ``Specimen``, ``Organization``, …) are skipped
+at the file level by the bundled ``glob_patterns``. To include them, override
+``glob_patterns=`` at the constructor and add a ``resource_specs:`` entry plus
+matching ``tables:`` entry in a copy of the YAML.
+
+Customising
+-----------
+
+The bundled config is the easiest starting point for authoring a similar ingest
+for other FHIR exports. Copy
+``pyhealth/datasets/fhir/configs/mimic4fhir.yaml``, edit the
+``resource_specs:`` and ``tables:`` blocks for the resources you care about,
+and either:
+
+* pass ``config_path=...`` directly to ``FHIRDataset(root=..., config_path=...)``, or
+* subclass ``FHIRDataset`` and set ``DEFAULT_CONFIG_PATH`` on the subclass.
+
+See the "Customising for a non-MIMIC FHIR export" section of
+:doc:`pyhealth.datasets.FHIRDataset` for the step-by-step.
+
+API reference
+-------------
+
+.. autoclass:: pyhealth.datasets.MIMIC4FHIR
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/api/datasets/pyhealth.datasets.PhysioNetDeIDDataset.rst b/docs/api/datasets/pyhealth.datasets.PhysioNetDeIDDataset.rst
new file mode 100644
index 000000000..4e04cd629
--- /dev/null
+++ b/docs/api/datasets/pyhealth.datasets.PhysioNetDeIDDataset.rst
@@ -0,0 +1,9 @@
+pyhealth.datasets.PhysioNetDeIDDataset
+=======================================
+
+The PhysioNet De-Identification dataset. For more information see `here `_. Access requires PhysioNet credentialing.
+
+.. autoclass:: pyhealth.datasets.PhysioNetDeIDDataset
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/api/graph.rst b/docs/api/graph.rst
index e69de29bb..164214285 100644
--- a/docs/api/graph.rst
+++ b/docs/api/graph.rst
@@ -0,0 +1,138 @@
+Graph
+=====
+
+The ``pyhealth.graph`` module lets you bring a healthcare knowledge graph into
+your PyHealth pipeline. Graph-based models like GraphCare and GNN can use
+relational medical knowledge — drug interactions, disease hierarchies,
+symptom–diagnosis links — to enrich patient representations beyond what
+raw EHR codes alone can capture.
+
+What Is a Knowledge Graph?
+---------------------------
+
+A knowledge graph encodes medical relationships as **(head, relation, tail)**
+triples. For example:
+
+- ``("aspirin", "treats", "headache")``
+- ``("metformin", "used_for", "type_2_diabetes")``
+- ``("ICD9:250", "is_a", "ICD9:249")``
+
+PyHealth does not ship a built-in graph — you bring triples from a source of
+your choice (UMLS, DrugBank, an ICD hierarchy, a custom ontology, etc.) and
+the :class:`~pyhealth.graph.KnowledgeGraph` class handles indexing, entity
+mappings, and k-hop subgraph extraction. The typical use case is querying the
+graph at training time: given a patient's active codes, extract the local
+subgraph around those codes and feed it to a graph-aware model.
+
+Getting Started
+---------------
+
+The simplest way to create a graph is to pass a list of triples directly:
+
+.. code-block:: python
+
+ from pyhealth.graph import KnowledgeGraph
+
+ triples = [
+ ("aspirin", "treats", "headache"),
+ ("headache", "symptom_of", "migraine"),
+ ("ibuprofen", "treats", "headache"),
+ ]
+ kg = KnowledgeGraph(triples=triples)
+ kg.stat()
+ # KnowledgeGraph: 4 entities, 2 relations, 3 triples
+
+For larger graphs it is more practical to load from a CSV or TSV file. The
+file should have columns named ``head``, ``relation``, and ``tail``:
+
+.. code-block:: python
+
+ kg = KnowledgeGraph(triples="path/to/medical_kg.tsv")
+
+Exploring the Graph
+-------------------
+
+Once built, you can inspect the graph and look up neighbours for any entity:
+
+.. code-block:: python
+
+ kg.num_entities # total unique entities
+ kg.num_relations # total unique relation types
+ kg.num_triples # total edges
+
+ kg.has_entity("aspirin") # True / False
+ kg.neighbors("aspirin") # list of (relation, tail) pairs
+
+ # Integer ID mappings used internally by PyG
+ kg.entity2id["aspirin"] # → int
+ kg.id2entity[0] # → entity name string
+
+Extracting Patient Subgraphs
+------------------------------
+
+The main reason to build a knowledge graph is to extract a patient-specific
+subgraph at training time. ``subgraph()`` returns all entities reachable
+within *n* hops of a set of seed codes, as a PyTorch Geometric ``Data``
+object:
+
+.. code-block:: python
+
+ patient_codes = ["ICD9:250.00", "NDC:0069-0105"]
+ subgraph = kg.subgraph(seed_entities=patient_codes, num_hops=2)
+
+.. note::
+
+ ``subgraph()`` requires `PyTorch Geometric `_
+ (``torch_geometric``). The graph can still be constructed and explored
+ without it — only subgraph extraction needs PyG.
+
+ Install with: ``pip install torch-geometric``
+
+Using with GraphProcessor in a Task
+-------------------------------------
+
+To feed subgraphs into a model automatically during data loading, pass a
+configured :class:`~pyhealth.processors.GraphProcessor` instance in your
+task's ``input_schema``. The processor will call ``kg.subgraph()`` for each
+patient sample:
+
+.. code-block:: python
+
+ from pyhealth.graph import KnowledgeGraph
+ from pyhealth.processors import GraphProcessor
+ from pyhealth.tasks import BaseTask
+
+ kg = KnowledgeGraph(triples="medical_kg.tsv")
+
+ class MyGraphTask(BaseTask):
+ task_name = "MyGraphTask"
+ input_schema = {
+ "conditions": "sequence",
+ "kg_subgraph": GraphProcessor(kg, num_hops=2),
+ }
+ output_schema = {"label": "binary"}
+
+ def __call__(self, patient):
+ ...
+
+Pre-computed Node Embeddings
+-----------------------------
+
+If you already have entity embeddings (e.g. from TransE or an LLM), you can
+attach them to the graph at construction time. The model can then use these
+as initial node features instead of learning them from scratch:
+
+.. code-block:: python
+
+ import torch
+
+ node_features = torch.randn(kg.num_entities, 64) # (num_entities, feat_dim)
+ kg = KnowledgeGraph(triples=triples, node_features=node_features)
+
+API Reference
+-------------
+
+.. toctree::
+ :maxdepth: 3
+
+ graph/pyhealth.graph.KnowledgeGraph
diff --git a/docs/api/metrics.rst b/docs/api/metrics.rst
index 1767e0026..9e6bc160a 100644
--- a/docs/api/metrics.rst
+++ b/docs/api/metrics.rst
@@ -7,6 +7,8 @@ For applicable tasks, we provide the relevant metrics for model calibration, as
Among these we also provide metrics related to uncertainty quantification, for model calibration, as well as metrics that measure the quality of prediction sets
We also provide other metrics specically for healthcare
tasks, such as drug drug interaction (DDI) rate.
+For synthetic (generative) EHR data, we provide privacy, utility, and statistical
+fidelity metrics.
.. toctree::
@@ -19,3 +21,4 @@ tasks, such as drug drug interaction (DDI) rate.
metrics/pyhealth.metrics.prediction_set
metrics/pyhealth.metrics.fairness
metrics/pyhealth.metrics.interpretability
+ metrics/pyhealth.metrics.generative
diff --git a/docs/api/metrics/pyhealth.metrics.generative.rst b/docs/api/metrics/pyhealth.metrics.generative.rst
new file mode 100644
index 000000000..85e448a52
--- /dev/null
+++ b/docs/api/metrics/pyhealth.metrics.generative.rst
@@ -0,0 +1,25 @@
+pyhealth.metrics.generative
+===================================
+
+Evaluation metrics for synthetic (generative) EHR data, covering privacy,
+utility, and statistical fidelity.
+
+.. currentmodule:: pyhealth.metrics.generative
+
+.. autofunction:: evaluate_synthetic_ehr
+
+Privacy metrics
+-------------------------------------
+
+.. autofunction:: calc_nnaar
+
+.. autofunction:: calc_membership_inference
+
+.. autofunction:: compute_discriminator_privacy
+
+Utility and fidelity metrics
+-------------------------------------
+
+.. autofunction:: compute_mle
+
+.. autofunction:: compute_prevalence_metrics
diff --git a/docs/api/models.rst b/docs/api/models.rst
index 2621b6a2a..d695b20db 100644
--- a/docs/api/models.rst
+++ b/docs/api/models.rst
@@ -1,9 +1,171 @@
Models
===============
-We implement the following models for supporting multiple healthcare predictive tasks.
+PyHealth models sit between the :doc:`processors` (which turn raw patient data
+into tensors) and the :doc:`trainer` (which runs the training loop). Each
+model takes a ``SampleDataset`` — the result of ``dataset.set_task()`` — as
+its first constructor argument, and uses it to automatically build the right
+embedding layers and output head for your task.
+
+One thing worth knowing up front: the ``SampleDataset`` carries fitted
+processor metadata that the model needs to configure itself. If you pass the
+raw ``BaseDataset`` instead you'll get an error, because it hasn't been
+processed into samples yet.
+
+Choosing a Model
+----------------
+
+The table below covers the most commonly used models and when each one fits
+best. If your features are a mix of sequential codes and static numeric
+vectors, ``MultimodalRNN`` is usually the easiest starting point because it
+routes each feature type automatically.
+
+.. list-table::
+ :header-rows: 1
+ :widths: 20 40 40
+
+ * - Model
+ - Good fit when…
+ - Notes
+ * - :doc:`models/pyhealth.models.RNN`
+ - Your features are sequences of medical codes (diagnoses, procedures, drugs) across visits
+ - One RNN per feature, hidden states concatenated; ``rnn_type`` can be ``"GRU"`` (default) or ``"LSTM"``
+ * - :doc:`models/pyhealth.models.Transformer`
+ - You have longer code histories and want attention to capture long-range dependencies
+ - Self-attention across the sequence; tends to work well when visit order matters
+ * - :doc:`models/pyhealth.models.MLP`
+ - Features are static numeric vectors (aggregated lab values, demographics)
+ - Fully connected; no notion of sequence order
+ * - ``MultimodalRNN``
+ - Features mix sequential codes with static tensors or multi-hot encodings
+ - Auto-routes sequential features to RNN layers and non-sequential features to linear layers; good default for EHR
+ * - :doc:`models/pyhealth.models.StageNet`
+ - You have time-stamped vital signs with irregular measurement intervals
+ - Requires ``StageNetProcessor`` or ``StageNetTensorProcessor`` in the task schema
+ * - :doc:`models/pyhealth.models.GNN`
+ - Features include graph-structured data
+ - Works with ``GraphProcessor``; see :doc:`graph` for setup
+ * - :doc:`models/pyhealth.models.GraphCare`
+ - You want to augment EHR codes with a medical knowledge graph
+ - Combines code sequences with a :class:`~pyhealth.graph.KnowledgeGraph`
+
+How BaseModel Works
+--------------------
+
+All PyHealth models inherit from ``BaseModel``, which itself inherits from
+PyTorch's ``nn.Module``. When you call ``MyModel(dataset=sample_ds)``, the
+base class reads the dataset's schemas and automatically sets:
+
+- ``self.feature_keys`` — the list of input field names from ``input_schema``
+- ``self.label_keys`` — the list of output field names from ``output_schema``
+- ``self.device`` — the compute device
+
+It also provides three helper methods that take care of the boilerplate that
+varies by task type:
+
+- ``self.get_output_size()`` returns the output dimension from the fitted
+ label processor, so you don't have to hard-code it.
+- ``self.get_loss_function()`` returns the right loss for the task: BCE for
+ binary and multilabel tasks, cross-entropy for multiclass, MSE for
+ regression.
+- ``self.prepare_y_prob(logits)`` applies sigmoid, softmax, or identity to
+ logits depending on the task, producing calibrated probabilities.
+
+The ``forward()`` method is expected to return a dictionary with four keys:
+``loss``, ``y_prob``, ``y_true``, and ``logit``. The Trainer reads all four.
+
+EmbeddingModel
+--------------
+
+:class:`~pyhealth.models.EmbeddingModel` is a helper that routes each input
+feature to the appropriate embedding layer based on how its processor works.
+Features from token-based processors (``SequenceProcessor``,
+``NestedSequenceProcessor``, and similar) get a learned ``nn.Embedding``
+lookup. Features from continuous processors (``TensorProcessor``,
+``TimeseriesProcessor``, ``MultiHotProcessor``) get a linear projection
+instead. You end up with a uniform embedding shape across all features:
+
+.. code-block:: python
+
+ self.embedding_model = EmbeddingModel(dataset, embedding_dim=128)
+ embedded = self.embedding_model(inputs, masks=masks)
+ # embedded[key] has shape (batch_size, seq_len, embedding_dim)
+
+Task Mode and Loss Functions
+-----------------------------
+
+PyHealth automatically selects the loss function and output activation based
+on the label processor in your task's ``output_schema``:
+
+.. list-table::
+ :header-rows: 1
+ :widths: 20 30 30
+
+ * - Output schema value
+ - Loss function
+ - ``y_prob`` shape and activation
+ * - ``"binary"``
+ - BCE with logits
+ - sigmoid → (batch, 1)
+ * - ``"multiclass"``
+ - Cross-entropy
+ - softmax → (batch, num_classes)
+ * - ``"multilabel"``
+ - BCE with logits
+ - sigmoid → (batch, num_labels)
+ * - ``"regression"``
+ - MSE
+ - identity → (batch, 1)
+
+Building a Custom Model
+-----------------------
+
+If none of the built-in models fit your architecture, you can subclass
+``BaseModel`` directly. The skeleton below shows the typical structure: build
+an ``EmbeddingModel`` in ``__init__``, unpack processor schemas in
+``forward``, pool or aggregate the embeddings, and return the four-key dict.
+
+.. code-block:: python
+
+ from pyhealth.models import BaseModel
+ from pyhealth.models.embedding import EmbeddingModel
+ import torch
+ import torch.nn as nn
+
+ class MyModel(BaseModel):
+ def __init__(self, dataset, embedding_dim=128):
+ super().__init__(dataset=dataset)
+ self.label_key = self.label_keys[0]
+ self.embedding_model = EmbeddingModel(dataset, embedding_dim)
+ self.fc = nn.Linear(embedding_dim * len(self.feature_keys),
+ self.get_output_size())
+
+ def forward(self, **kwargs):
+ inputs, masks = {}, {}
+ for key in self.feature_keys:
+ feature = kwargs[key]
+ if isinstance(feature, torch.Tensor):
+ feature = (feature,)
+ schema = self.dataset.input_processors[key].schema()
+ inputs[key] = feature[schema.index("value")]
+ if "mask" in schema:
+ masks[key] = feature[schema.index("mask")]
+
+ embedded = self.embedding_model(inputs, masks=masks)
+ pooled = [embedded[k].mean(dim=1) for k in self.feature_keys]
+ logits = self.fc(torch.cat(pooled, dim=1))
+
+ y_true = kwargs[self.label_key].to(self.device)
+ return {
+ "loss": self.get_loss_function()(logits, y_true),
+ "y_prob": self.prepare_y_prob(logits),
+ "y_true": y_true,
+ "logit": logits,
+ }
+
+API Reference
+-------------
-
.. toctree::
:maxdepth: 3
@@ -15,13 +177,16 @@ We implement the following models for supporting multiple healthcare predictive
models/pyhealth.models.GNN
models/pyhealth.models.Transformer
models/pyhealth.models.TransformersModel
+ models/pyhealth.models.TransformerDeID
models/pyhealth.models.RETAIN
models/pyhealth.models.GAMENet
+ models/pyhealth.models.GraphCare
models/pyhealth.models.MICRON
models/pyhealth.models.SafeDrug
models/pyhealth.models.MoleRec
models/pyhealth.models.Deepr
models/pyhealth.models.EHRMamba
+ models/pyhealth.models.EHRMambaCEHR
models/pyhealth.models.JambaEHR
models/pyhealth.models.ContraWR
models/pyhealth.models.SparcNet
@@ -36,8 +201,14 @@ We implement the following models for supporting multiple healthcare predictive
models/pyhealth.models.TFMTokenizer
models/pyhealth.models.GAN
models/pyhealth.models.VAE
+ models/pyhealth.models.HALO
+ models/pyhealth.models.GPT2
+ models/pyhealth.models.PromptEHR
+ models/pyhealth.models.MedGAN
+ models/pyhealth.models.CorGAN
models/pyhealth.models.SDOH
models/pyhealth.models.VisionEmbeddingModel
models/pyhealth.models.TextEmbedding
models/pyhealth.models.BIOT
- models/pyhealth.models.unified_multimodal_embedding_docs
\ No newline at end of file
+ models/pyhealth.models.unified_multimodal_embedding_docs
+ models/pyhealth.models.califorest
diff --git a/docs/api/models/pyhealth.models.AdaCare.rst b/docs/api/models/pyhealth.models.AdaCare.rst
index 00aeaf4f0..4d988ba78 100644
--- a/docs/api/models/pyhealth.models.AdaCare.rst
+++ b/docs/api/models/pyhealth.models.AdaCare.rst
@@ -9,6 +9,11 @@ The separate callable AdaCareLayer and the complete AdaCare model.
:show-inheritance:
.. autoclass:: pyhealth.models.AdaCare
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+.. autoclass:: pyhealth.models.MultimodalAdaCare
:members:
:undoc-members:
:show-inheritance:
\ No newline at end of file
diff --git a/docs/api/models/pyhealth.models.CorGAN.rst b/docs/api/models/pyhealth.models.CorGAN.rst
new file mode 100644
index 000000000..783b3cafd
--- /dev/null
+++ b/docs/api/models/pyhealth.models.CorGAN.rst
@@ -0,0 +1,21 @@
+pyhealth.models.CorGAN
+===================================
+
+CorGAN: a Correlation-capturing Convolutional GAN for synthetic EHR generation.
+A 1D-CNN (or linear) autoencoder captures local code correlations, and a WGAN
+generator/critic are trained in the autoencoder's latent space. Ported from the
+reference implementation
+(`cor-gan `_) and wrapped as a PyHealth
+:class:`~pyhealth.models.BaseModel`.
+
+Reference:
+ Torfi, A., & Fox, E. A. (2020).
+ *CorGAN: Correlation-Capturing Convolutional Generative Adversarial
+ Networks for Generating Synthetic Healthcare Records.*
+ In Proceedings of the 33rd International FLAIRS Conference.
+ https://arxiv.org/abs/2001.09346
+
+.. autoclass:: pyhealth.models.CorGAN
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/api/models/pyhealth.models.EHRMambaCEHR.rst b/docs/api/models/pyhealth.models.EHRMambaCEHR.rst
new file mode 100644
index 000000000..c15a09962
--- /dev/null
+++ b/docs/api/models/pyhealth.models.EHRMambaCEHR.rst
@@ -0,0 +1,12 @@
+pyhealth.models.EHRMambaCEHR
+===================================
+
+EHRMambaCEHR applies CEHR-style embeddings (:class:`~pyhealth.models.cehr_embeddings.MambaEmbeddingsForCEHR`)
+and a stack of :class:`~pyhealth.models.MambaBlock` layers to a single FHIR token stream, for use with
+:class:`~pyhealth.tasks.mpf_clinical_prediction.MPFClinicalPredictionTask` and
+:class:`~pyhealth.datasets.fhir.FHIRDataset`.
+
+.. autoclass:: pyhealth.models.EHRMambaCEHR
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/api/models/pyhealth.models.GPT2.rst b/docs/api/models/pyhealth.models.GPT2.rst
new file mode 100644
index 000000000..9e13c10d4
--- /dev/null
+++ b/docs/api/models/pyhealth.models.GPT2.rst
@@ -0,0 +1,17 @@
+pyhealth.models.GPT2
+===================================
+
+A decoder-only GPT-2 baseline for unconditional synthetic EHR generation,
+wrapped as a PyHealth :class:`~pyhealth.models.BaseModel`. Patient visit-code
+sequences are serialized into causal-LM token streams and modeled
+autoregressively.
+
+Reference:
+ Radford, A., Wu, J., Child, R., Luan, D., Amodei, D., & Sutskever, I. (2019).
+ *Language Models are Unsupervised Multitask Learners.* OpenAI.
+ https://cdn.openai.com/better-language-models/language_models_are_unsupervised_multitask_learners.pdf
+
+.. autoclass:: pyhealth.models.GPT2
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/api/models/pyhealth.models.GraphCare.rst b/docs/api/models/pyhealth.models.GraphCare.rst
new file mode 100644
index 000000000..2c394d357
--- /dev/null
+++ b/docs/api/models/pyhealth.models.GraphCare.rst
@@ -0,0 +1,7 @@
+pyhealth.models.GraphCare
+=========================
+
+.. autoclass:: pyhealth.models.GraphCare
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/api/models/pyhealth.models.HALO.rst b/docs/api/models/pyhealth.models.HALO.rst
new file mode 100644
index 000000000..991fe3467
--- /dev/null
+++ b/docs/api/models/pyhealth.models.HALO.rst
@@ -0,0 +1,19 @@
+pyhealth.models.HALO
+===================================
+
+HALO (Hierarchical Autoregressive Language model) for synthetic EHR generation.
+A faithful port of the reference implementation
+(`HALO_Inpatient `_),
+wrapped as a PyHealth :class:`~pyhealth.models.BaseModel`.
+
+Reference:
+ Theodorou, B., Xiao, C., & Sun, J. (2023).
+ *Synthesize high-dimensional longitudinal electronic health records via
+ hierarchical autoregressive language model.*
+ Nature Communications, 14, 5305.
+ https://www.nature.com/articles/s41467-023-41093-0
+
+.. autoclass:: pyhealth.models.HALO
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/api/models/pyhealth.models.MedGAN.rst b/docs/api/models/pyhealth.models.MedGAN.rst
new file mode 100644
index 000000000..cd328f651
--- /dev/null
+++ b/docs/api/models/pyhealth.models.MedGAN.rst
@@ -0,0 +1,21 @@
+pyhealth.models.MedGAN
+===================================
+
+MedGAN: a bag-of-codes Generative Adversarial Network for synthetic EHR
+generation. An autoencoder is pre-trained on multi-hot patient records, then a
+GAN with residual generator and minibatch-averaging discriminator is trained in
+the autoencoder's latent space. Ported from the reference implementations
+(`medgan `_ and its PyTorch reimplementation)
+and wrapped as a PyHealth :class:`~pyhealth.models.BaseModel`.
+
+Reference:
+ Choi, E., Biswal, S., Malin, B., Duke, J., Stewart, W. F., & Sun, J. (2017).
+ *Generating Multi-label Discrete Patient Records using Generative
+ Adversarial Networks.*
+ In Proceedings of Machine Learning for Healthcare (MLHC) 2017.
+ https://arxiv.org/abs/1703.06490
+
+.. autoclass:: pyhealth.models.MedGAN
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/api/models/pyhealth.models.PromptEHR.rst b/docs/api/models/pyhealth.models.PromptEHR.rst
new file mode 100644
index 000000000..57ed8389f
--- /dev/null
+++ b/docs/api/models/pyhealth.models.PromptEHR.rst
@@ -0,0 +1,20 @@
+pyhealth.models.PromptEHR
+===================================
+
+PromptEHR: prompt-learning BART for synthetic EHR generation. A port of the
+reference implementation
+(`PromptEHR `_) that consumes the
+standard PyHealth interface and learns via a span-infilling objective with a
+reparameterized soft prompt.
+
+Reference:
+ Wang, Z., & Sun, J. (2022).
+ *PromptEHR: Conditional Electronic Healthcare Records Generation with
+ Prompt Learning.*
+ In Proceedings of EMNLP 2022.
+ https://aclanthology.org/2022.emnlp-main.185/
+
+.. autoclass:: pyhealth.models.PromptEHR
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/api/models/pyhealth.models.RETAIN.rst b/docs/api/models/pyhealth.models.RETAIN.rst
index 88899363e..ac8bbad06 100644
--- a/docs/api/models/pyhealth.models.RETAIN.rst
+++ b/docs/api/models/pyhealth.models.RETAIN.rst
@@ -9,6 +9,11 @@ The separate callable RETAINLayer and the complete RETAIN model.
:show-inheritance:
.. autoclass:: pyhealth.models.RETAIN
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+.. autoclass:: pyhealth.models.MultimodalRETAIN
:members:
:undoc-members:
:show-inheritance:
\ No newline at end of file
diff --git a/docs/api/models/pyhealth.models.TransformerDeID.rst b/docs/api/models/pyhealth.models.TransformerDeID.rst
new file mode 100644
index 000000000..d07aa94aa
--- /dev/null
+++ b/docs/api/models/pyhealth.models.TransformerDeID.rst
@@ -0,0 +1,9 @@
+pyhealth.models.TransformerDeID
+===================================
+
+Transformer-based token classifier for clinical text de-identification.
+
+.. autoclass:: pyhealth.models.TransformerDeID
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/api/models/pyhealth.models.califorest.rst b/docs/api/models/pyhealth.models.califorest.rst
new file mode 100644
index 000000000..69ee1ff9b
--- /dev/null
+++ b/docs/api/models/pyhealth.models.califorest.rst
@@ -0,0 +1,7 @@
+pyhealth.models.califorest
+==========================
+
+.. automodule:: pyhealth.models.califorest
+ :members:
+ :undoc-members:
+ :show-inheritance:
\ No newline at end of file
diff --git a/docs/api/overview.rst b/docs/api/overview.rst
new file mode 100644
index 000000000..532eeb48b
--- /dev/null
+++ b/docs/api/overview.rst
@@ -0,0 +1,240 @@
+PyHealth Architecture Overview
+==============================
+
+This page describes how all PyHealth components connect, from raw data files
+to a trained, evaluated model. Every stage has its own dedicated reference
+page — this overview is here to show how they fit together.
+
+Pipeline at a Glance
+---------------------
+
+.. code-block:: text
+
+ Raw CSV / Parquet files
+ │
+ ▼
+ config.yaml (table schemas, patient_id col, timestamp col, attributes)
+ │
+ ▼
+ BaseDataset subclass ──── loads tables, caches as global_event_df.parquet
+ │ .unique_patient_ids → List[str]
+ │ .get_patient(id) → Patient
+ │ .iter_patients() → Iterator[Patient]
+ │ .stats() → prints patient/event counts
+ │
+ ▼
+ BaseTask subclass (__call__(patient) → List[Dict])
+ │ .input_schema = {"feature": "processor_name", ...}
+ │ .output_schema = {"label": "binary" | "multiclass" | ...}
+ │
+ dataset.set_task(task, num_workers=N)
+ │
+ ▼
+ SampleDataset ──── len(ds), ds[i], patient_to_index, record_to_index
+ │ Backed by LitData streaming files
+ │ Processors fitted during set_task, applied at load time
+ │
+ get_dataloader(dataset, batch_size=32, shuffle=True)
+ │
+ ▼
+ Model(dataset, ...) ──── BaseModel subclass (RNN, Transformer, MLP, …)
+ │ EmbeddingModel routes features via processor.is_token()
+ │ forward(**batch) → {"loss", "y_prob", "y_true", "logit"}
+ │
+ ▼
+ Trainer(model, metrics=[...], device=...)
+ │ .train(train_dl, val_dl, test_dl, epochs=20, ...)
+ │ .evaluate(test_dl) → Dict[metric_name, value]
+ │
+ ├──▶ Calibration (pyhealth.calib)
+ │ TemperatureScaling / HistogramBinning / KCal / …
+ │ LABEL / SCRIB / FavMac / … (conformal prediction sets)
+ │
+ └──▶ Interpretability (pyhealth.interpret)
+ GradientSaliency / IntegratedGradients / DeepLift / SHAP / LIME / …
+
+
+Stage 1: Raw Data → BaseDataset
+---------------------------------
+
+See :doc:`datasets` for the full reference.
+
+PyHealth reads raw CSV or Parquet files using Polars, joins tables according
+to a ``config.yaml`` schema, and writes a compact
+``global_event_df.parquet`` cache. On subsequent runs with the same
+configuration it reads from the cache rather than re-parsing source files.
+
+**Native datasets** (MIMIC-III, MIMIC-IV, eICU, OMOP, and many others) have
+built-in schemas — just pass a ``root`` path and a list of ``tables``:
+
+.. code-block:: python
+
+ from pyhealth.datasets import MIMIC3Dataset
+
+ if __name__ == '__main__':
+ dataset = MIMIC3Dataset(
+ root="/data/mimiciii/1.4",
+ tables=["diagnoses_icd", "procedures_icd", "prescriptions"],
+ cache_dir=".cache",
+ dev=True, # cap at 1 000 patients during exploration
+ )
+
+**Custom datasets** subclass ``BaseDataset`` and provide a ``config_path``
+pointing to your own ``config.yaml``. If files need preprocessing (e.g.
+merging multiple CSVs), define ``preprocess_(self, df)`` on the
+subclass — it receives a narwhals LazyFrame and must return one.
+
+Key init params: ``root``, ``tables``, ``config_path`` (custom only),
+``cache_dir``, ``num_workers``, ``dev``.
+
+A UUID derived from ``(root, tables, dataset_name, dev)`` is appended to
+``cache_dir``, so different configurations never overwrite each other.
+
+
+Stage 2: Patient and Event Objects
+------------------------------------
+
+See :doc:`data` for the full reference.
+
+Once a dataset is loaded, ``Patient.get_events()`` is the primary query
+method:
+
+.. code-block:: python
+
+ events = patient.get_events(
+ event_type="diagnoses_icd", # must match the table name in config.yaml
+ start=datetime(2020, 1, 1),
+ end=datetime(2020, 6, 1),
+ filters=[("icd_code", "==", "250.00")],
+ )
+
+``Event`` attributes to keep in mind:
+
+- ``event.timestamp`` — always use this; PyHealth normalises ``charttime``,
+ ``admittime``, etc. into a single property.
+- ``event.attr_dict`` / ``event["col_name"]`` / ``event.col_name`` — access
+ attribute values. All column names are **lowercased** at ingest time.
+
+
+Stage 3: Task Definition → set_task
+--------------------------------------
+
+See :doc:`tasks` for the full reference.
+
+A ``BaseTask`` subclass defines three things:
+
+- ``task_name: str`` — must be assigned (not just annotated).
+- ``input_schema`` / ``output_schema`` — dicts mapping sample keys to
+ processor string aliases (e.g. ``"sequence"``, ``"binary"``).
+- ``__call__(self, patient) → List[Dict]`` — extracts features from one
+ ``Patient``; return ``[]`` to skip a patient.
+
+``dataset.set_task(task, num_workers=N)`` iterates all patients, collects
+samples, fits processors, and writes LitData ``.ld`` streaming files to disk.
+The result is a ``SampleDataset``.
+
+.. important::
+
+ All code calling ``set_task()`` must live inside
+ ``if __name__ == '__main__':``. PyHealth uses multiprocessing internally
+ and will crash without this guard.
+
+
+Stage 4: Processors → SampleDataset
+--------------------------------------
+
+See :doc:`processors` for the full reference.
+
+When ``set_task()`` runs:
+
+1. ``SampleBuilder.fit(samples)`` — calls ``processor.fit(samples, field)``
+ for every schema field.
+2. ``SampleBuilder.transform(sample)`` — calls ``processor.process(value)``
+ for every field, writing tensors to disk.
+
+The key signal for model routing is ``processor.is_token()``:
+
+- ``True`` → ``nn.Embedding`` (discrete token indices, e.g. medical codes)
+- ``False`` → ``nn.Linear`` (continuous values, e.g. time series, images)
+
+
+Stage 5: Model Initialization and Forward Pass
+------------------------------------------------
+
+See :doc:`models` for the full reference.
+
+.. code-block:: python
+
+ from pyhealth.models import RNN
+
+ model = RNN(dataset=sample_dataset, embedding_dim=128, hidden_dim=64)
+
+The model reads ``dataset.input_schema``, ``dataset.output_schema``, and
+``dataset.input_processors`` to auto-build embedding layers and the output
+head. **Always pass the ``SampleDataset`` (result of ``set_task()``), not
+the raw ``BaseDataset``.**
+
+``model(**batch)`` where ``batch`` is a dict from the DataLoader. Must return
+``{"loss", "y_prob", "y_true", "logit"}``.
+
+**Choosing a model:**
+
+- Mixed sequential + static features → ``MultimodalRNN``
+- Purely sequential codes → ``RNN`` or ``Transformer``
+- Static feature vector → ``MLP``
+- Time-stamped vitals with irregular intervals → ``StageNet``
+- Graph-structured features → ``GNN`` or ``GraphCare`` (see :doc:`graph`)
+
+
+Stage 6: Training and Evaluation
+----------------------------------
+
+See :doc:`trainer` for the full reference.
+
+.. code-block:: python
+
+ from pyhealth.trainer import Trainer
+ from pyhealth.datasets import get_dataloader
+
+ train_dl = get_dataloader(train_ds, batch_size=32, shuffle=True)
+ val_dl = get_dataloader(val_ds, batch_size=32, shuffle=False)
+ test_dl = get_dataloader(test_ds, batch_size=32, shuffle=False)
+
+ trainer = Trainer(model=model, metrics=["roc_auc_macro", "f1_macro"], device="cuda")
+ trainer.train(train_dl, val_dl, test_dl, epochs=20,
+ monitor="roc_auc_macro", monitor_criterion="max", patience=5)
+
+ scores = trainer.evaluate(test_dl)
+ # → {"roc_auc_macro": 0.85, "loss": 0.3, ...}
+
+Split by patient to avoid data leakage:
+
+.. code-block:: python
+
+ all_ids = list(sample_dataset.patient_to_index.keys())
+ # ... split all_ids into train_ids / val_ids / test_ids ...
+ train_indices = [i for pid in train_ids for i in sample_dataset.patient_to_index[pid]]
+ train_ds = sample_dataset.subset(train_indices)
+
+
+Common Pitfalls
+----------------
+
+.. list-table::
+ :header-rows: 1
+ :widths: 45 55
+
+ * - Mistake
+ - Fix
+ * - Missing ``if __name__ == '__main__':``
+ - Wrap all ``set_task()`` / dataset loading code in this guard
+ * - ``event.charttime`` instead of ``event.timestamp``
+ - Always use ``event.timestamp``
+ * - Task sample key doesn't match ``input_schema``
+ - Keys in ``__call__`` return dict must exactly match schema keys
+ * - ``dev=True`` during full training
+ - Only use ``dev=True`` during exploration; set ``dev=False`` for final runs
+ * - Passing ``BaseDataset`` to the model
+ - Pass ``SampleDataset`` (result of ``set_task()``) to the model
+ * - ``dataset.patients``
+ - Does not exist; use ``dataset.unique_patient_ids`` + ``dataset.get_patient(id)``
diff --git a/docs/api/processors.rst b/docs/api/processors.rst
index 3a9fb73de..a06e3c955 100644
--- a/docs/api/processors.rst
+++ b/docs/api/processors.rst
@@ -3,6 +3,12 @@ Processors
Processors in PyHealth handle data preprocessing and transformation for healthcare predictive tasks. They convert raw data into tensors suitable for machine learning models.
+Processors sit between :doc:`tasks` (which define *what* data to extract) and
+:doc:`models` (which consume the resulting tensors). You rarely call processors
+directly — they are configured through the ``input_schema`` and
+``output_schema`` of a task and applied automatically during
+``dataset.set_task()``.
+
Overview
--------
diff --git a/docs/api/tasks.rst b/docs/api/tasks.rst
index 3ed1e1c97..8724176a8 100644
--- a/docs/api/tasks.rst
+++ b/docs/api/tasks.rst
@@ -18,7 +18,7 @@ Getting Started
New to PyHealth tasks? Start here:
-- **Tutorial**: `Introduction to pyhealth.tasks `_ - Learn the basics of defining and using tasks
+- **Tutorial**: `Introduction to pyhealth.tasks `_ - Learn the basics of defining and using tasks
- **Code Examples**: Browse all examples online at https://github.com/sunlabuiuc/PyHealth/tree/master/examples
- **Pipeline Examples**: Check out our :doc:`../tutorials` page for complete end-to-end examples including:
@@ -67,6 +67,138 @@ After you define a task:
- Discover how to customize processor behavior with kwargs tuples
- Understand processor types for different data modalities (text, images, signals, etc.)
+Writing a Custom Task
+----------------------
+
+When a built-in task doesn't match your cohort or prediction target, you can
+define your own by subclassing :class:`~pyhealth.tasks.BaseTask`. The class
+needs three things: a name, input and output schemas, and a ``__call__``
+method that processes one patient at a time.
+
+.. code-block:: python
+
+ from pyhealth.tasks import BaseTask
+ from pyhealth.data import Patient
+ from typing import List, Dict, Any
+
+ class MyMortalityTask(BaseTask):
+ task_name: str = "MyMortalityTask"
+
+ input_schema: Dict[str, str] = {
+ "conditions": "sequence", # maps to SequenceProcessor
+ "procedures": "sequence",
+ }
+ output_schema: Dict[str, str] = {
+ "label": "binary" # maps to BinaryLabelProcessor
+ }
+
+ def __call__(self, patient: Patient) -> List[Dict[str, Any]]:
+ samples = []
+ for adm in patient.get_events("admissions"):
+ label = 1 if adm.hospital_expire_flag == "1" else 0
+
+ # Fetch historical diagnoses up to this admission
+ conditions = patient.get_events("diagnoses_icd", end=adm.timestamp)
+ cond_codes = [e.icd_code for e in conditions]
+
+ if not cond_codes:
+ continue
+
+ samples.append({
+ "conditions": [cond_codes], # wrapped in a list for the sequence processor
+ "procedures": [[]],
+ "label": label,
+ })
+ return samples
+
+The ``__call__`` method receives one ``Patient`` and returns a list of sample
+dictionaries. Each dictionary's keys should match the schemas you declared.
+Returning an empty list is fine — PyHealth simply skips that patient. Note
+that event attribute names are always lowercase (e.g. ``e.icd_code`` rather
+than ``e.ICD_CODE``) because PyHealth lowercases all column names at ingest
+time. Timestamps are accessed through ``event.timestamp`` rather than the
+original column name like ``charttime``, since PyHealth normalises them into
+a single property.
+
+Processor String Keys
+----------------------
+
+The string values in your schemas map to specific processor classes. Here is
+a quick reference:
+
+.. list-table::
+ :header-rows: 1
+ :widths: 25 35 40
+
+ * - String key
+ - Processor
+ - Typical use
+ * - ``"sequence"``
+ - ``SequenceProcessor``
+ - Diagnosis codes, procedure codes, drug names
+ * - ``"nested_sequence"``
+ - ``NestedSequenceProcessor``
+ - Cumulative visit history (drug recommendation, readmission)
+ * - ``"tensor"``
+ - ``TensorProcessor``
+ - Aggregated numeric values (e.g. last lab value per item)
+ * - ``"timeseries"``
+ - ``TimeseriesProcessor``
+ - Irregular time-series measurements
+ * - ``"multi_hot"``
+ - ``MultiHotProcessor``
+ - Demographics, comorbidity flags
+ * - ``"text"``
+ - ``TextProcessor``
+ - Clinical notes
+ * - ``"binary"``
+ - ``BinaryLabelProcessor``
+ - Binary classification label (0 / 1)
+ * - ``"multiclass"``
+ - ``MultiClassLabelProcessor``
+ - Multi-class label
+ * - ``"multilabel"``
+ - ``MultiLabelProcessor``
+ - Multi-label classification
+ * - ``"regression"``
+ - ``RegressionLabelProcessor``
+ - Continuous regression target
+
+How set_task() Works
+---------------------
+
+Calling ``dataset.set_task(task)`` iterates over every patient in the
+dataset, runs your ``__call__`` method on each one, fits all the processors
+on the collected samples, then serialises everything to disk as LitData
+``.ld`` files. The result is a :class:`~pyhealth.datasets.SampleDataset` that
+supports ``len()`` and index access, ready for a DataLoader.
+
+.. code-block:: python
+
+ sample_ds = dataset.set_task(MyMortalityTask(), num_workers=4)
+ len(sample_ds) # total ML samples across all patients
+ sample_ds[0] # a single sample dict with tensor values
+
+If you re-run ``set_task()`` with the same task and processor configuration,
+PyHealth detects the existing cache and skips reprocessing. During
+development it is useful to set ``dev=True`` on the dataset, which limits
+processing to 1 000 patients so iterations are fast.
+
+.. note::
+
+ **A note on multiprocessing.** ``set_task()`` can spawn worker processes
+ when ``num_workers > 1``. On macOS and Linux this requires the standard
+ Python multiprocessing guard around your top-level script:
+
+ .. code-block:: python
+
+ if __name__ == '__main__':
+ sample_ds = dataset.set_task(task, num_workers=4)
+
+ Without this guard, Python may try to re-import and re-run the script in
+ each worker process, leading to infinite recursion. This is a general
+ Python multiprocessing requirement, not specific to PyHealth.
+
Available Tasks
---------------
@@ -80,10 +212,10 @@ Available Tasks
COVID-19 CXR Classification
DKA Prediction (MIMIC-IV)
Drug Recommendation
- EEG Abnormal
- EEG Events
+ EHR Generation
Length of Stay Prediction
Medical Transcriptions Classification
+ MPF Clinical Prediction (FHIR)
Mortality Prediction (Next Visit)
Mortality Prediction (StageNet MIMIC-IV)
Patient Linkage (MIMIC-III)
@@ -94,6 +226,7 @@ Available Tasks
Sleep Staging v2
Benchmark EHRShot
ChestX-ray14 Binary Classification
+ De-Identification NER
ChestX-ray14 Multilabel Classification
Variant Classification (ClinVar)
Mutation Pathogenicity (COSMIC)
diff --git a/docs/api/tasks/pyhealth.tasks.DeIDNERTask.rst b/docs/api/tasks/pyhealth.tasks.DeIDNERTask.rst
new file mode 100644
index 000000000..2b7428f6e
--- /dev/null
+++ b/docs/api/tasks/pyhealth.tasks.DeIDNERTask.rst
@@ -0,0 +1,7 @@
+pyhealth.tasks.DeIDNERTask
+=======================================
+
+.. autoclass:: pyhealth.tasks.DeIDNERTask
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/api/tasks/pyhealth.tasks.EEG_abnormal.rst b/docs/api/tasks/pyhealth.tasks.EEG_abnormal.rst
deleted file mode 100644
index c6ca62bd0..000000000
--- a/docs/api/tasks/pyhealth.tasks.EEG_abnormal.rst
+++ /dev/null
@@ -1,4 +0,0 @@
-pyhealth.tasks.EEG_abnormal
-=======================================
-
-.. autofunction:: pyhealth.tasks.EEG_abnormal.EEG_isAbnormal_fn
\ No newline at end of file
diff --git a/docs/api/tasks/pyhealth.tasks.EEG_events.rst b/docs/api/tasks/pyhealth.tasks.EEG_events.rst
deleted file mode 100644
index 62b15963c..000000000
--- a/docs/api/tasks/pyhealth.tasks.EEG_events.rst
+++ /dev/null
@@ -1,4 +0,0 @@
-pyhealth.tasks.EEG_events
-=======================================
-
-.. autofunction:: pyhealth.tasks.EEG_events.EEG_events_fn
\ No newline at end of file
diff --git a/docs/api/tasks/pyhealth.tasks.generate_ehr.rst b/docs/api/tasks/pyhealth.tasks.generate_ehr.rst
new file mode 100644
index 000000000..77c332f33
--- /dev/null
+++ b/docs/api/tasks/pyhealth.tasks.generate_ehr.rst
@@ -0,0 +1,32 @@
+pyhealth.tasks.generate_ehr
+===========================================
+
+Task that turns a longitudinal EHR dataset into per-patient, per-visit code
+sequences for training unconditional synthetic-EHR generators (HALO, GPT2,
+PromptEHR, MedGAN, CorGAN), plus helpers to flatten generated output into the
+long-form dataframe consumed by :mod:`pyhealth.metrics.generative`.
+
+Task Classes
+------------
+
+.. autoclass:: pyhealth.tasks.generate_ehr.EHRGeneration
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+.. autoclass:: pyhealth.tasks.generate_ehr.EHRGenerationMIMIC3
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+.. autoclass:: pyhealth.tasks.generate_ehr.EHRGenerationMIMIC4
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+Helper Functions
+----------------
+
+.. autofunction:: pyhealth.tasks.generate_ehr.decode_dataset
+
+.. autofunction:: pyhealth.tasks.generate_ehr.to_evaluation_dataframe
diff --git a/docs/api/tasks/pyhealth.tasks.mpf_clinical_prediction.rst b/docs/api/tasks/pyhealth.tasks.mpf_clinical_prediction.rst
new file mode 100644
index 000000000..27331905f
--- /dev/null
+++ b/docs/api/tasks/pyhealth.tasks.mpf_clinical_prediction.rst
@@ -0,0 +1,12 @@
+pyhealth.tasks.mpf_clinical_prediction
+======================================
+
+Multitask Prompted Fine-tuning (MPF) style binary clinical prediction on FHIR
+token timelines, paired with :class:`~pyhealth.datasets.FHIRDataset` and
+:class:`~pyhealth.models.EHRMambaCEHR`. Based on CEHR / EHRMamba ideas
+(EHRMamba, arXiv:2405.14567): https://arxiv.org/abs/2405.14567.
+
+.. autoclass:: pyhealth.tasks.MPFClinicalPredictionTask
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/api/trainer.rst b/docs/api/trainer.rst
index 039f22dc4..0a52af0f7 100644
--- a/docs/api/trainer.rst
+++ b/docs/api/trainer.rst
@@ -1,7 +1,102 @@
Trainer
-===================================
+=======
+
+:class:`~pyhealth.trainer.Trainer` handles the PyTorch training loop for you.
+Rather than writing your own epoch loop, loss backward pass, optimizer step,
+and metric evaluation, you hand the Trainer your model and data loaders and
+let it manage the details — including early stopping when validation
+performance plateaus and automatic reloading of the best checkpoint at the end.
+
+A Typical Training Run
+-----------------------
+
+Here is what a full training setup looks like. The data loaders come from
+``get_dataloader()`` in :mod:`pyhealth.datasets`, which knows how to work with
+PyHealth's LitData caching format:
+
+.. code-block:: python
+
+ from pyhealth.trainer import Trainer
+ from pyhealth.datasets import get_dataloader
+
+ train_loader = get_dataloader(train_ds, batch_size=32, shuffle=True)
+ val_loader = get_dataloader(val_ds, batch_size=32, shuffle=False)
+ test_loader = get_dataloader(test_ds, batch_size=32, shuffle=False)
+
+ trainer = Trainer(
+ model=model,
+ metrics=["roc_auc_macro", "pr_auc_macro", "f1_macro"],
+ device="cuda",
+ )
+
+ trainer.train(
+ train_dataloader=train_loader,
+ val_dataloader=val_loader,
+ test_dataloader=test_loader,
+ epochs=50,
+ monitor="roc_auc_macro",
+ monitor_criterion="max",
+ patience=10,
+ )
+
+ scores = trainer.evaluate(test_loader)
+ # {'roc_auc_macro': 0.85, 'pr_auc_macro': 0.79, 'f1_macro': 0.72, 'loss': 0.31}
+
+Setting Up the Trainer
+-----------------------
+
+``Trainer(model, metrics=None, device=None, enable_logging=True, output_path=None, exp_name=None)``
+
+- **model** — your instantiated PyHealth model.
+- **metrics** — the metric names you want computed at validation and test time
+ (e.g. ``["roc_auc_macro", "f1_macro"]``). See :doc:`metrics` for the full
+ list of supported strings.
+- **device** — ``"cuda"`` or ``"cpu"``; defaults to auto-detecting a GPU.
+- **enable_logging** — when enabled, the Trainer creates a timestamped folder
+ under ``output_path`` with a ``log.txt`` and model checkpoints.
+- **output_path** / **exp_name** — where and how to name the output folder.
+
+Controlling the Training Loop
+------------------------------
+
+``trainer.train()`` accepts these key arguments beyond the data loaders:
+
+- **epochs** — the maximum number of training epochs.
+- **optimizer_class** / **optimizer_params** — which optimizer to use and how
+ to configure it. Defaults to ``Adam`` with a learning rate of ``1e-3``.
+- **weight_decay** — L2 regularisation strength. Default ``0.0``.
+- **max_grad_norm** — if set, clips gradients to this norm before each update,
+ which can help stabilise training on noisy medical data.
+- **monitor** / **monitor_criterion** — the metric to watch on the validation
+ set (e.g. ``"roc_auc_macro"``) and whether higher is better (``"max"``) or
+ lower is better (``"min"``). The Trainer saves a checkpoint whenever this
+ metric improves.
+- **patience** — how many epochs without improvement to wait before stopping
+ early.
+- **load_best_model_at_last** — when ``True`` (the default), the Trainer
+ restores the best checkpoint at the end of training rather than keeping the
+ weights from the final epoch.
+
+Getting the Test Scores
+------------------------
+
+``trainer.train()`` prints test scores to the console when a
+``test_dataloader`` is provided, but it does not return them as a Python
+object. To capture results for downstream use, call ``evaluate()`` separately:
+
+.. code-block:: python
+
+ scores = trainer.evaluate(test_loader)
+ # scores is a plain dict, e.g. {'roc_auc_macro': 0.85, 'loss': 0.31}
+
+ import json
+ with open("results.json", "w") as f:
+ json.dump(scores, f, indent=2)
+
+API Reference
+-------------
.. autoclass:: pyhealth.trainer.Trainer
:members:
:undoc-members:
- :show-inheritance:
\ No newline at end of file
+ :show-inheritance:
diff --git a/docs/conf.py b/docs/conf.py
index 1591cdd47..72d742b7b 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -190,6 +190,7 @@
}
html_css_files = ["css/override.css", "css/sphinx_gallery.css"]
+html_js_files = ["external_links.js"]
html_show_sphinx = False
# -- Options for HTMLHelp output ---------------------------------------------
diff --git a/docs/faq.rst b/docs/faq.rst
index 1c846c264..67764b5cb 100644
--- a/docs/faq.rst
+++ b/docs/faq.rst
@@ -4,32 +4,36 @@ Frequently Asked Questions
----
-Blueprint & Development Plan
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+What does PyHealth 2.0 support?
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
-The long term goal of PyHealth is to become a comprehensive healthcare AI toolkit that supports
-beyond EHR data, but also the images and clinical notes.
+PyHealth 2.0 is a comprehensive healthcare AI toolkit that goes beyond
+structured EHR data: it provides a unified API for EHR tables, medical
+images, biosignals (EEG/ECG/sleep), clinical text, and genomics. The
+:doc:`architecture overview ` describes how datasets, tasks,
+processors, models, and the trainer fit together.
-This is the central place to track important things to be fixed/added:
+Highlights of the 2.0 release:
-- The support of image datasets and clinical notes
-- The compatibility and the support of OMOP format datasets
-- Model persistence (save, load, and portability)
-- The release of a benchmark paper with PyHealth
-- Add contact channel with `Gitter `_
-- Support additional languages, see `Manage Translations `_
+- Native datasets for MIMIC-III/IV, eICU, OMOP-CDM, FHIR, and many
+ modality-specific collections (chest X-ray, sleep staging, EEG, ECG).
+- A 5-stage pipeline (dataset → task → processors → model → trainer) with
+ caching and dynamic scaling from laptop to cluster.
+- Post-hoc model calibration (``pyhealth.calib``) and interpretability
+ (``pyhealth.interpret``) that plug into any trained model.
-Feel free to open on issue report if needed.
-See `Issues `_.
+For the roadmap and ways to get involved, see :doc:`how_to_contribute`
+and the `open issues `_.
Inclusion Criteria
^^^^^^^^^^^^^^^^^^
-Similarly to Similarly to scikit-learn, We mainly consider well-established algorithms for inclusion.
-A rule of thumb is at least two years since publication, 50+ citations, and usefulness.
-
-However, we encourage the author(s) of newly proposed models to share and add your implementation into combo
-for boosting ML accessibility and reproducibility.
-This exception only applies if you could commit to the maintenance of your model for at least two year period.
+Similarly to scikit-learn, we mainly consider well-established algorithms
+for inclusion. A rule of thumb is at least two years since publication,
+50+ citations, and usefulness.
+However, we encourage the author(s) of newly proposed models to share and
+add your implementation into PyHealth for boosting ML accessibility and
+reproducibility. This exception only applies if you could commit to the
+maintenance of your model for at least a two-year period.
diff --git a/docs/index.rst b/docs/index.rst
index a876b9ff4..375d90e38 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -7,12 +7,19 @@ Welcome to PyHealth
**The Python Library for Healthcare AI**
-Build, test, and deploy healthcare machine learning models with ease. PyHealth is designed for both **ML researchers and medical practitioners**. We can make your **healthcare AI applications** easier to develop, test and validate. Your development process becomes more flexible and more customizable. `[GitHub] `_
+.. card:: 🌐 Visit the PyHealth Project Website
+ :link: https://pyhealth.dev
+ :link-type: url
+ :class-card: sd-bg-primary sd-text-white sd-text-center
+
+ **pyhealth.dev** — the new home for PyHealth news, updates, and resources →
+
+Build, test, and deploy healthcare machine learning models with ease. PyHealth is designed for both **ML researchers and medical practitioners**. We can make your **healthcare AI applications** easier to develop, test and validate. Your development process becomes more flexible and more customizable. `[GitHub] `_
**Key Features**
- **Dramatically simpler**: Build any healthcare AI model in ~7 lines of code
-- **Blazing fast**: Up to 39× faster than pandas
+- **Blazing fast**: Up to 39× faster than pandas for task processing
- **Memory efficient**: Runs on 16GB laptops
- **True multimodal**: Unified API for EHR, medical images, biosignals, clinical text, and genomics
- **Production-ready**: 25+ pre-built models, 20+ tasks, 12+ datasets with comprehensive evaluation tools
@@ -35,12 +42,12 @@ Build, test, and deploy healthcare machine learning models with ease. PyHealth i
:target: https://pypi.org/project/pyhealth/
:alt: PyPI version
-.. image:: https://img.shields.io/github/stars/yzhao062/pyhealth.svg
+.. image:: https://img.shields.io/github/stars/sunlabuiuc/pyhealth.svg
:target: https://github.com/sunlabuiuc/pyhealth/stargazers
:alt: GitHub stars
-.. image:: https://img.shields.io/github/forks/yzhao062/pyhealth.svg?color=blue
+.. image:: https://img.shields.io/github/forks/sunlabuiuc/pyhealth.svg?color=blue
:target: https://github.com/sunlabuiuc/pyhealth/network
:alt: GitHub forks
@@ -62,39 +69,13 @@ Build, test, and deploy healthcare machine learning models with ease. PyHealth i
----------
- **[News!]** Join us for **PyHealth Casual Chats** – informal sessions where you can ask questions, discuss research ideas, or talk about PyHealth developments! Everyone is welcome. `Join Zoom → `_ | `Add to Calendar → `_
+ **[News!]** Join us for **PyHealth Casual Chats** – informal sessions where you can ask questions, discuss research ideas, or talk about PyHealth developments! Everyone is welcome. `Join Zoom → `_ | `Add to Calendar → `_
**[News!]** We are continuously implementing good papers and benchmarks into PyHealth, checkout the `[Planned List] `_. Welcome to pick one from the list and send us a PR or add more influential and new papers into the plan list.
----------
-.. -----
-
-
-.. **Build Status & Coverage & Maintainability & License**
-
-.. .. image:: https://travis-ci.org/yzhao062/pyhealth.svg?branch=master
-.. :target: https://travis-ci.org/yzhao062/pyhealth
-.. :alt: Build Status
-
-
-.. .. image:: https://ci.appveyor.com/api/projects/status/1kupdy87etks5n3r/branch/master?svg=true
-.. :target: https://ci.appveyor.com/project/yzhao062/pyhealth/branch/master
-.. :alt: Build status
-
-
-.. .. image:: https://api.codeclimate.com/v1/badges/bdc3d8d0454274c753c4/maintainability
-.. :target: https://codeclimate.com/github/yzhao062/pyhealth/maintainability
-.. :alt: Maintainability
-
-
-.. .. image:: https://img.shields.io/github/license/yzhao062/pyhealth
-.. :target: https://github.com/yzhao062/pyhealth/blob/master/LICENSE
-.. :alt: License
-
-
-
Get Started in Minutes
=============================
@@ -189,15 +170,6 @@ Quick Navigation
-.. **Key Links and Resources**\ :
-
-
-.. * `View the latest codes on Github `_
-.. * `Execute Interactive Jupyter Notebooks `_
-.. * `Check out the PyHealth paper `_
-
-
-
----
@@ -221,6 +193,7 @@ Quick Navigation
:hidden:
:caption: Documentation
+ api/overview
api/data
api/datasets
api/graph
diff --git a/docs/log.rst b/docs/log.rst
index 429801aa0..3c2e8b76b 100644
--- a/docs/log.rst
+++ b/docs/log.rst
@@ -2,6 +2,16 @@ Development logs
======================
We track the new development here:
+**2026**
+
+.. code-block:: rst
+
+ 1. PyHealth 2.0 release: unified multimodal pipeline (EHR, images,
+ biosignals, clinical text, genomics), Polars-backed dataset loading
+ with caching, processor-based feature schemas, LitData-backed
+ SampleDataset streaming, and the PyHealth 2.0 paper
+ (arXiv:2601.16414).
+
**Dec 29, 2023**
..code-blocks:: rst
diff --git a/docs/research_initiative.rst b/docs/research_initiative.rst
index f7ddaba14..f631c0dd2 100644
--- a/docs/research_initiative.rst
+++ b/docs/research_initiative.rst
@@ -11,6 +11,12 @@ about advancing computational healthcare, regardless of their career stage or in
participants work on innovative projects that advance the field of computational healthcare, contributing
to publications, open-source software, and the broader healthcare AI community.
+Our goals are to build:
+
+1. **Easily accessible and reproducible research** — Making healthcare AI research transparent and replicable
+2. **Solutions to real-world healthcare problems** — Tackling important clinical challenges with practical impact
+3. **Connections with healthcare professionals** — Bridging the gap between AI researchers and clinical practitioners
+
The initiative provides participants with hands-on experience in:
- **Healthcare AI Research**: Working on real-world healthcare problems using electronic health records (EHRs) and clinical data
@@ -19,17 +25,45 @@ The initiative provides participants with hands-on experience in:
- **Academic Publishing**: Co-authoring research papers and presenting findings
- **Collaborative Research**: Working alongside researchers and industry partners
-Mission & Goals
----------------
+Program Logistics
+-----------------
-We're an open-source community of researchers with the goal of building:
+**Format**: Remote
-1. **Easily accessible and reproducible research** — Making healthcare AI research transparent and replicable
-2. **Solutions to real-world healthcare problems** — Tackling important clinical challenges with practical impact
-3. **Connections with healthcare professionals** — Bridging the gap between AI researchers and clinical practitioners
+**Time Commitment**: 10–20 hours per week during active research cycles
+
+**Eligibility**: Open to anyone! We welcome people from all backgrounds—students, engineers, researchers,
+and healthcare professionals. We don't care about your title or institution. We only ask that you have the
+ability to write decent-quality code and are self-driven to work hard on healthcare problems.
+See `How to Apply`_ to get started.
+
+**Open Projects**: Browse available research projects and find one that matches your interests:
+`Open Projects List `_
-We connect those who want to work on healthcare problems with industry and academic collaborators to create
-meaningful impact in healthcare AI.
+The program runs on a rolling, year-round basis with recurring terms aligned to major healthcare AI
+conference cycles. Each term culminates in a submission to a top-tier venue.
+
+.. list-table:: Upcoming Research Terms
+ :widths: 20 25 30 25
+ :header-rows: 1
+ :class: research-table
+
+ * - Term
+ - Period
+ - Target Conference
+ - Est. Submission Deadline
+ * - Summer Term
+ - Apr – Aug 2026
+ - `ML4H 2026 `_
+ - ~Sep 2026
+ * - Fall Term
+ - Sep – Dec 2026
+ - `CHIL 2027 `_
+ - ~Feb 2027
+ * - Spring Term
+ - Jan – Apr 2027
+ - `MLHC 2027 `_
+ - ~May 2027
Research Contributions
----------------------
@@ -48,6 +82,11 @@ drug recommendation, and healthcare AI infrastructure.
- Paper Title
- Venue
- Links
+ * - 2026
+ - Arjun Chatterjee, Sayeed Sajjad Razin
+ - Making Conformal Predictors Robust in Healthcare Settings: a Case Study on EEG Classification
+ - Under Review at AIME
+ - `Paper `_
* - 2025
- Zilal Eiz Al Din
- MIMIC-RD: Can LLMs differentially diagnose rare diseases in real-world clinical settings?
@@ -71,31 +110,6 @@ drug recommendation, and healthcare AI infrastructure.
**Latest from ML4H 2025**: Our first cohort successfully published three papers at ML4H, covering rare diseases,
social determinants of health, and prostate cancer genomics—all at the forefront of healthcare and AI research!
-Program Information
--------------------
-
-**Duration**: Ongoing year-round program with targeted submission cycles
-
-**Format**: Remote
-
-**Time Commitment**: 10–20 hours per week during active research cycles
-
-**Eligibility**: Open to anyone! We welcome people from all backgrounds—students, engineers, researchers,
-and healthcare professionals. We don't care about your title or institution. We only ask that you have the
-ability to write decent-quality code and are self-driven to work hard on healthcare problems.
-
-**Current Cycle**: Spring 2026, targeting `MLHC 2026 `_ (Machine Learning for Healthcare)
-
-**Key Dates for Spring 2026**:
-
-- **Official start date**: January 20, 2026 — Kickoff meeting (details via Discord)
-- **Pre-submission intent deadline**: April 10, 2026
-- **Full submission deadline**: April 11, 2026
-- **Conference dates**: August 15–16, 2026
-
-**Open Projects**: Browse available research projects and find one that matches your interests:
-`Open Projects List `_
-
Research Areas
--------------
diff --git a/docs/tutorials.rst b/docs/tutorials.rst
index fcdab84ea..1be57fe15 100644
--- a/docs/tutorials.rst
+++ b/docs/tutorials.rst
@@ -4,21 +4,21 @@ Tutorials
We provide the following tutorials to help users get started with our pyhealth. Please bear with us as we update the documentation on how to use pyhealth 2.0.
-`Tutorial 0: Introduction to pyhealth.data `_ `[Video] `_
+`Tutorial 0: Introduction to pyhealth.data `_ `[Video] `_
-`Tutorial 1: Introduction to pyhealth.datasets `_ `[Video (PyHealth 1.16)] `_
+`Tutorial 1: Introduction to pyhealth.datasets `_ `[Video (PyHealth 1.x legacy)] `_
-`Tutorial 2: Introduction to pyhealth.tasks `_ `[Video (PyHealth 1.16)] `_
+`Tutorial 2: Introduction to pyhealth.tasks `_ `[Video (PyHealth 1.x legacy)] `_
-`Tutorial 3: Introduction to pyhealth.models `_ `[Video] `_
+`Tutorial 3: Introduction to pyhealth.models `_ `[Video] `_
-`Tutorial 4: Introduction to pyhealth.trainer `_ `[Video] `_
+`Tutorial 4: Introduction to pyhealth.trainer `_ `[Video] `_
-`Tutorial 5: Introduction to pyhealth.metrics `_ `[Video] `_
+`Tutorial 5: Introduction to pyhealth.metrics `_ `[Video] `_
-`Tutorial 6: Introduction to pyhealth.tokenizer `_ `[Video] `_
+`Tutorial 6: Introduction to pyhealth.tokenizer `_ `[Video] `_
-`Tutorial 7: Introduction to pyhealth.medcode `_ `[Video] `_
+`Tutorial 7: Introduction to pyhealth.medcode `_ `[Video] `_
Data Access Guide
diff --git a/examples/benchmark_perf/loc/minimal_drug_rec.py b/examples/benchmark_perf/loc/minimal_drug_rec.py
index d35a23336..4aa00e244 100644
--- a/examples/benchmark_perf/loc/minimal_drug_rec.py
+++ b/examples/benchmark_perf/loc/minimal_drug_rec.py
@@ -1,7 +1,23 @@
-from pyhealth.datasets import MIMIC4Dataset
-from pyhealth.tasks import DrugRecommendationMIMIC4
-base_dataset = MIMIC4Dataset(
- ehr_root="/srv/local/data/physionet.org/files/mimiciv/2.2/",
- ehr_tables=["patients", "admissions", "diagnoses_icd", "procedures_icd", "prescriptions"],
-)
-sample_dataset = base_dataset.set_task(DrugRecommendationMIMIC4())
\ No newline at end of file
+import polars as pl; from pyhealth.datasets import MIMIC4Dataset; from pyhealth.tasks.base_task import BaseTask
+class DrugRecommendationMIMIC4(BaseTask):
+ task_name="DrugRecommendationMIMIC4"
+ input_schema={"conditions":"nested_sequence","procedures":"nested_sequence","drugs_hist":"nested_sequence"}
+ output_schema={"drugs":"multilabel"}
+ def __call__(self,p):
+ adms=p.get_events(event_type="admissions")
+ if len(adms)<2: return []
+ S=[]
+ for adm in adms:
+ f=[("hadm_id","==",adm.hadm_id)]
+ c=p.get_events("diagnoses_icd",filters=f,return_df=True).select(pl.concat_str(["diagnoses_icd/icd_version","diagnoses_icd/icd_code"],separator="_")).to_series().to_list()
+ r=p.get_events("procedures_icd",filters=f,return_df=True).select(pl.concat_str(["procedures_icd/icd_version","procedures_icd/icd_code"],separator="_")).to_series().to_list()
+ d=[x[:4] for x in p.get_events("prescriptions",filters=f,return_df=True).select(pl.col("prescriptions/ndc")).to_series().to_list() if x]
+ if not(c and r and d): continue
+ S.append({"visit_id":adm.hadm_id,"patient_id":p.patient_id,"conditions":c,"procedures":r,"drugs":d,"drugs_hist":d})
+ if len(S)<2: return []
+ S[0].update({"conditions":[S[0]["conditions"]],"procedures":[S[0]["procedures"]],"drugs_hist":[S[0]["drugs_hist"]]})
+ for i in range(1,len(S)): S[i]["conditions"]=S[i-1]["conditions"]+[S[i]["conditions"]]; S[i]["procedures"]=S[i-1]["procedures"]+[S[i]["procedures"]]; S[i]["drugs_hist"]=S[i-1]["drugs_hist"]+[S[i]["drugs_hist"]]
+ for i in range(len(S)): S[i]["drugs_hist"][i]=[]
+ return S
+base_dataset=MIMIC4Dataset(ehr_root="/srv/local/data/physionet.org/files/mimiciv/2.2/",ehr_tables=["patients","admissions","diagnoses_icd","procedures_icd","prescriptions"])
+sample_dataset=base_dataset.set_task(DrugRecommendationMIMIC4())
diff --git a/examples/benchmark_perf/loc/minimal_legacy_drug_rec.py b/examples/benchmark_perf/loc/minimal_legacy_drug_rec.py
index 48c42bb28..a805ab07b 100644
--- a/examples/benchmark_perf/loc/minimal_legacy_drug_rec.py
+++ b/examples/benchmark_perf/loc/minimal_legacy_drug_rec.py
@@ -1,7 +1,16 @@
-from pyhealth.datasets import MIMIC4Dataset
-from pyhealth.tasks import drug_recommendation_mimic4_fn
-base_dataset = MIMIC4Dataset(root="/srv/local/data/physionet.org/files/mimiciv/2.0/hosp",
- tables=["diagnoses_icd", "procedures_icd", "prescriptions"], dev=False,
- code_mapping={"NDC": "ATC"}, refresh_cache=True)
-sample_dataset = base_dataset.set_task(task_fn=drug_recommendation_mimic4_fn)
-print(f"Samples: {len(sample_dataset.samples)}")
+from pyhealth.data import Patient,Visit; from pyhealth.datasets import MIMIC4Dataset
+
+def drug_recommendation_mimic4_fn(patient):
+ S=[]
+ for v in patient:
+ c=v.get_code_list(table="diagnoses_icd"); r=v.get_code_list(table="procedures_icd"); d=[x[:4] for x in v.get_code_list(table="prescriptions")]
+ if not(c and r and d): continue
+ S.append({"visit_id":v.visit_id,"patient_id":patient.patient_id,"conditions":c,"procedures":r,"drugs":d,"drugs_hist":d})
+ if len(S)<2: return []
+ S[0].update({"conditions":[S[0]["conditions"]],"procedures":[S[0]["procedures"]],"drugs_hist":[S[0]["drugs_hist"]]})
+ for i in range(1,len(S)): S[i]["conditions"]=S[i-1]["conditions"]+[S[i]["conditions"]]; S[i]["procedures"]=S[i-1]["procedures"]+[S[i]["procedures"]]; S[i]["drugs_hist"]=S[i-1]["drugs_hist"]+[S[i]["drugs_hist"]]
+ for i in range(len(S)): S[i]["drugs_hist"][i]=[]
+ return S
+
+base_dataset=MIMIC4Dataset(root="/srv/local/data/physionet.org/files/mimiciv/2.0/hosp",tables=["diagnoses_icd","procedures_icd","prescriptions"],dev=False,code_mapping={"NDC":"ATC"},refresh_cache=True)
+sample_dataset=base_dataset.set_task(task_fn=drug_recommendation_mimic4_fn)
diff --git a/examples/benchmark_perf/loc/minimal_legacy_los.py b/examples/benchmark_perf/loc/minimal_legacy_los.py
index 7d30b4919..2bc8eae8e 100644
--- a/examples/benchmark_perf/loc/minimal_legacy_los.py
+++ b/examples/benchmark_perf/loc/minimal_legacy_los.py
@@ -1,7 +1,14 @@
from pyhealth.datasets import MIMIC4Dataset
-from pyhealth.tasks import length_of_stay_prediction_mimic4_fn
-base_dataset = MIMIC4Dataset(root="/srv/local/data/physionet.org/files/mimiciv/2.0/hosp",
- tables=["diagnoses_icd", "procedures_icd", "prescriptions"], dev=False,
- code_mapping={"ICD10PROC": "CCSPROC", "NDC": "ATC"}, refresh_cache=True)
-sample_dataset = base_dataset.set_task(task_fn=length_of_stay_prediction_mimic4_fn)
-print(f"Samples: {len(sample_dataset.samples)}")
+
+def categorize_los(d): return 0 if d<1 else (d if d<=7 else (8 if d<=14 else 9))
+
+def length_of_stay_prediction_mimic4_fn(patient):
+ S=[]
+ for v in patient:
+ c=v.get_code_list(table="diagnoses_icd"); r=v.get_code_list(table="procedures_icd"); d=v.get_code_list(table="prescriptions")
+ if not(c and r and d): continue
+ S.append({"visit_id":v.visit_id,"patient_id":patient.patient_id,"conditions":[c],"procedures":[r],"drugs":[d],"label":categorize_los((v.discharge_time-v.encounter_time).days)})
+ return S
+
+base_dataset=MIMIC4Dataset(root="/srv/local/data/physionet.org/files/mimiciv/2.0/hosp",tables=["diagnoses_icd","procedures_icd","prescriptions"],dev=False,code_mapping={"ICD10PROC":"CCSPROC","NDC":"ATC"},refresh_cache=True)
+sample_dataset=base_dataset.set_task(task_fn=length_of_stay_prediction_mimic4_fn)
diff --git a/examples/benchmark_perf/loc/minimal_legacy_mortality.py b/examples/benchmark_perf/loc/minimal_legacy_mortality.py
index b285e6789..719959f56 100644
--- a/examples/benchmark_perf/loc/minimal_legacy_mortality.py
+++ b/examples/benchmark_perf/loc/minimal_legacy_mortality.py
@@ -1,24 +1,27 @@
-from typing import List
-from pyhealth.datasets import MIMIC4Dataset
-from pyhealth.data import Patient, Visit
-LAB_ITEM_IDS = {"50824", "52455", "50983", "52623", "50822", "52452", "50971", "52610",
- "50806", "52434", "50902", "52535", "50803", "50804", "50809", "52027",
- "50931", "52569", "50808", "51624", "50960", "50868", "52500", "52031",
- "50964", "51701", "50970"}
-def mortality_task_fn(patient: Patient) -> List[dict]:
- samples = []
- for i in range(len(patient) - 1):
- visit, next_visit = patient[i], patient[i + 1]
- mortality_label = int(next_visit.discharge_status) if next_visit.discharge_status in [0, 1] else 0
- conditions = visit.get_code_list(table="diagnoses_icd")
- procedures = visit.get_code_list(table="procedures_icd")
- labs = list(dict.fromkeys([e.code for e in visit.get_event_list(table="labevents") if e.code in LAB_ITEM_IDS]))
- if conditions and labs:
- samples.append({"visit_id": visit.visit_id, "patient_id": patient.patient_id,
- "conditions": [conditions], "procedures": [procedures] if procedures else [[]],
- "labs": [labs], "label": mortality_label})
- return samples
-base_dataset = MIMIC4Dataset(root="/srv/local/data/physionet.org/files/mimiciv/2.0/hosp",
- tables=["diagnoses_icd", "procedures_icd", "labevents"], dev=False, refresh_cache=True)
-sample_dataset = base_dataset.set_task(task_fn=mortality_task_fn)
-print(f"Samples: {len(sample_dataset.samples)}")
+from collections import defaultdict; from pyhealth.data import Patient; from pyhealth.datasets import MIMIC4Dataset
+from typing import Dict,List
+
+LAB_CATS:Dict[str,List[str]]={"Sodium":["50824","52455","50983","52623"],"Potassium":["50822","52452","50971","52610"],"Chloride":["50806","52434","50902","52535"],"Bicarbonate":["50803","50804"],"Glucose":["50809","52027","50931","52569"],"Calcium":["50808","51624"],"Magnesium":["50960"],"Anion Gap":["50868","52500"],"Osmolality":["52031","50964","51701"],"Phosphate":["50970"]}
+LAB_NAMES=list(LAB_CATS); LABITEMS=[x for ids in LAB_CATS.values() for x in ids]
+
+def mortality_task_fn(patient):
+ icd_d,icd_t,lab_v,lab_t,mort,prev=[],[],[],[],0,None
+ for v in patient:
+ at=v.encounter_time; dt=v.discharge_time
+ if at is None or dt is None or dt0:
+ ldf=ldf.with_columns(pl.col("labevents/storetime").str.strptime(pl.Datetime,"%Y-%m-%d %H:%M:%S")).filter(pl.col("labevents/storetime")<=dt).select(pl.col("timestamp"),pl.col("labevents/itemid"),pl.col("labevents/valuenum").cast(pl.Float64))
+ for ts in sorted(ldf["timestamp"].unique().to_list()):
+ r=ldf.filter(pl.col("timestamp")==ts)
+ vec=[next((r.filter(pl.col("labevents/itemid")==iid)["labevents/valuenum"][0] for iid in self.LAB_CATS[cat] if r.filter(pl.col("labevents/itemid")==iid).height>0),None) for cat in self.LAB_NAMES]
+ lab_v.append(vec); lab_t.append((ts-at).total_seconds()/3600.0)
+ if not lab_v or not icd_d: return []
+ return [{"patient_id":p.patient_id,"icd_codes":(icd_t,icd_d),"labs":(lab_t,lab_v),"mortality":mort}]
+base_dataset=MIMIC4Dataset(ehr_root="/srv/local/data/physionet.org/files/mimiciv/2.2/",ehr_tables=["patients","admissions","diagnoses_icd","procedures_icd","labevents"])
+sample_dataset=base_dataset.set_task(MortalityPredictionStageNetMIMIC4())
diff --git a/examples/conformal_eeg/test_tfm_tuab_inference.py b/examples/conformal_eeg/test_tfm_tuab_inference.py
new file mode 100644
index 000000000..400518469
--- /dev/null
+++ b/examples/conformal_eeg/test_tfm_tuab_inference.py
@@ -0,0 +1,197 @@
+"""
+Quick inference test: TFMTokenizer on TUAB using local weightfiles/.
+
+Two weight setups (ask your PI which matches their training):
+
+ 1) Default (matches conformal example scripts):
+ - tokenizer: weightfiles/tfm_tokenizer_last.pth (multi-dataset tokenizer)
+ - classifier: weightfiles/TFM_Tokenizer_multiple_finetuned_on_TUAB/.../best_model.pth
+
+ 2) PI benchmark TUAB-specific files (place in weightfiles/):
+ - tokenizer: tfm_tokenizer_tuab.pth
+ - classifier: tfm_encoder_best_model_tuab.pth
+ Use: --pi-tuab-weights
+
+Split modes:
+ - conformal (default): same test set as conformal runs (TUH eval via patient conformal split).
+ - pi_benchmark: train/val ratio [0.875, 0.125] on train partition; test = TUH eval (same patients as official eval).
+
+Usage:
+ python examples/conformal_eeg/test_tfm_tuab_inference.py
+ python examples/conformal_eeg/test_tfm_tuab_inference.py --pi-tuab-weights
+ python examples/conformal_eeg/test_tfm_tuab_inference.py \\
+ --tuab-pi-weights-dir /shared/eng/conformal_eeg --split pi_benchmark
+ python examples/conformal_eeg/test_tfm_tuab_inference.py --tokenizer-weights PATH --classifier-weights PATH
+"""
+
+import argparse
+import os
+import time
+
+import torch
+
+from pyhealth.datasets import (
+ TUABDataset,
+ get_dataloader,
+ split_by_patient_conformal_tuh,
+ split_by_patient_tuh,
+)
+from pyhealth.models import TFMTokenizer
+from pyhealth.tasks import EEGAbnormalTUAB
+from pyhealth.trainer import Trainer
+
+REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+WEIGHTFILES = os.path.join(REPO_ROOT, "weightfiles")
+DEFAULT_TOKENIZER = os.path.join(WEIGHTFILES, "tfm_tokenizer_last.pth")
+CLASSIFIER_WEIGHTS_DIR = os.path.join(
+ WEIGHTFILES, "TFM_Tokenizer_multiple_finetuned_on_TUAB"
+)
+PI_TOKENIZER = os.path.join(WEIGHTFILES, "tfm_tokenizer_tuab.pth")
+PI_CLASSIFIER = os.path.join(WEIGHTFILES, "tfm_encoder_best_model_tuab.pth")
+
+
+def main():
+ parser = argparse.ArgumentParser(description="TFM TUAB inference sanity check")
+ parser.add_argument(
+ "--root",
+ type=str,
+ default="/srv/local/data/TUH/tuh_eeg_abnormal/v3.0.0/edf",
+ help="Path to TUAB edf/ directory.",
+ )
+ parser.add_argument("--gpu_id", type=int, default=0)
+ parser.add_argument(
+ "--seed",
+ type=int,
+ default=1,
+ choices=[1, 2, 3, 4, 5],
+ help="Which fine-tuned classifier folder _1.._5 (only if not using --classifier-weights).",
+ )
+ parser.add_argument(
+ "--pi-tuab-weights",
+ action="store_true",
+ help="Use PI TUAB-specific files under weightfiles/: tfm_tokenizer_tuab.pth, "
+ "tfm_encoder_best_model_tuab.pth",
+ )
+ parser.add_argument(
+ "--tuab-pi-weights-dir",
+ type=str,
+ default=None,
+ metavar="DIR",
+ help="Directory containing PI's TUAB TFM files (e.g. /shared/eng/conformal_eeg). "
+ "Loads tfm_tokenizer_tuab.pth + tfm_encoder_best_model_tuab.pth from there. "
+ "Overrides --pi-tuab-weights and default weightfiles paths unless "
+ "--tokenizer-weights / --classifier-weights are set explicitly.",
+ )
+ parser.add_argument(
+ "--tokenizer-weights",
+ type=str,
+ default=None,
+ help="Override tokenizer checkpoint path.",
+ )
+ parser.add_argument(
+ "--classifier-weights",
+ type=str,
+ default=None,
+ help="Override classifier checkpoint path (single .pth file).",
+ )
+ parser.add_argument(
+ "--split",
+ type=str,
+ choices=["conformal", "pi_benchmark"],
+ default="conformal",
+ help="conformal: same as EEG conformal scripts; pi_benchmark: 0.875/0.125 train/val on train partition.",
+ )
+ parser.add_argument(
+ "--split-seed",
+ type=int,
+ default=42,
+ help="RNG seed for patient shuffle (pi_benchmark and conformal).",
+ )
+ args = parser.parse_args()
+ device = f"cuda:{args.gpu_id}" if torch.cuda.is_available() else "cpu"
+
+ if args.tuab_pi_weights_dir is not None:
+ d = os.path.expanduser(args.tuab_pi_weights_dir)
+ tok = os.path.join(d, "tfm_tokenizer_tuab.pth")
+ cls_path = os.path.join(d, "tfm_encoder_best_model_tuab.pth")
+ elif args.pi_tuab_weights:
+ tok = PI_TOKENIZER
+ cls_path = PI_CLASSIFIER
+ else:
+ tok = DEFAULT_TOKENIZER
+ cls_path = os.path.join(
+ CLASSIFIER_WEIGHTS_DIR,
+ f"TFM_Tokenizer_multiple_finetuned_on_TUAB_{args.seed}",
+ "best_model.pth",
+ )
+
+ if args.tokenizer_weights is not None:
+ tok = args.tokenizer_weights
+ if args.classifier_weights is not None:
+ cls_path = args.classifier_weights
+
+ print(f"Device: {device}")
+ print(f"TUAB root: {args.root}")
+ print(f"Split mode: {args.split}")
+ print(f"Tokenizer weights: {tok}")
+ print(f"Classifier weights: {cls_path}")
+
+ t0 = time.time()
+ base_dataset = TUABDataset(root=args.root, subset="both")
+ print(f"Dataset loaded in {time.time() - t0:.1f}s")
+
+ t0 = time.time()
+ sample_dataset = base_dataset.set_task(
+ EEGAbnormalTUAB(
+ resample_rate=200,
+ normalization="95th_percentile",
+ compute_stft=True,
+ ),
+ num_workers=16,
+ )
+ print(f"Task set in {time.time() - t0:.1f}s | total samples: {len(sample_dataset)}")
+
+ if args.split == "conformal":
+ _, _, _, test_ds = split_by_patient_conformal_tuh(
+ dataset=sample_dataset,
+ ratios=[0.6, 0.2, 0.2],
+ seed=args.split_seed,
+ )
+ else:
+ _, _, test_ds = split_by_patient_tuh(
+ sample_dataset,
+ [0.875, 0.125],
+ seed=args.split_seed,
+ )
+
+ test_loader = get_dataloader(test_ds, batch_size=32, shuffle=False)
+ print(f"Test set size: {len(test_ds)}")
+
+ model = TFMTokenizer(dataset=sample_dataset).to(device)
+ model.load_pretrained_weights(
+ tokenizer_checkpoint_path=tok,
+ classifier_checkpoint_path=cls_path,
+ )
+
+ trainer = Trainer(
+ model=model,
+ device=device,
+ metrics=[
+ "accuracy",
+ "balanced_accuracy",
+ "f1_weighted",
+ "f1_macro",
+ "roc_auc_weighted_ovr",
+ ],
+ enable_logging=False,
+ )
+ t0 = time.time()
+ results = trainer.evaluate(test_loader)
+ print(f"\nEval time: {time.time() - t0:.1f}s")
+ print("\n=== Test Results ===")
+ for metric, value in results.items():
+ print(f" {metric}: {value:.4f}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/conformal_eeg/test_tfm_tuev_inference.py b/examples/conformal_eeg/test_tfm_tuev_inference.py
new file mode 100644
index 000000000..fede48d4d
--- /dev/null
+++ b/examples/conformal_eeg/test_tfm_tuev_inference.py
@@ -0,0 +1,112 @@
+"""
+Quick inference test: TFMTokenizer on TUEV using local weightfiles/.
+
+Mirrors the PI's benchmark script but uses the weightfiles/ paths already
+present in this repo. No training — pure inference to verify weights and
+normalization are correct.
+
+Usage:
+ python examples/conformal_eeg/test_tfm_tuev_inference.py
+ python examples/conformal_eeg/test_tfm_tuev_inference.py --gpu_id 1
+ python examples/conformal_eeg/test_tfm_tuev_inference.py --seed 2 # use _2/best_model.pth
+"""
+
+import argparse
+import os
+import time
+
+import torch
+
+from pyhealth.datasets import TUEVDataset, get_dataloader, split_by_patient_conformal_tuh
+from pyhealth.models import TFMTokenizer
+from pyhealth.tasks import EEGEventsTUEV
+from pyhealth.trainer import Trainer
+
+TUEV_ROOT = "/srv/local/data/TUH/tuh_eeg_events/v2.0.0/edf/"
+
+REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+TOKENIZER_WEIGHTS = os.path.join(REPO_ROOT, "weightfiles", "tfm_tokenizer_last.pth")
+CLASSIFIER_WEIGHTS_DIR = os.path.join(
+ REPO_ROOT, "weightfiles", "TFM_Tokenizer_multiple_finetuned_on_TUEV"
+)
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--gpu_id", type=int, default=0)
+ parser.add_argument(
+ "--seed", type=int, default=1, choices=[1, 2, 3, 4, 5],
+ help="Which fine-tuned classifier to use (1-5)."
+ )
+ args = parser.parse_args()
+ device = f"cuda:{args.gpu_id}" if torch.cuda.is_available() else "cpu"
+
+ classifier_weights = os.path.join(
+ CLASSIFIER_WEIGHTS_DIR,
+ f"TFM_Tokenizer_multiple_finetuned_on_TUEV_{args.seed}",
+ "best_model.pth",
+ )
+
+ print(f"Device: {device}")
+ print(f"Tokenizer weights: {TOKENIZER_WEIGHTS}")
+ print(f"Classifier weights: {classifier_weights}")
+
+ # ------------------------------------------------------------------ #
+ # STEP 1: Load dataset
+ # ------------------------------------------------------------------ #
+ t0 = time.time()
+ base_dataset = TUEVDataset(root=TUEV_ROOT, subset="both")
+ print(f"Dataset loaded in {time.time() - t0:.1f}s")
+
+ # ------------------------------------------------------------------ #
+ # STEP 2: Set task — normalization="95th_percentile" matches training
+ # ------------------------------------------------------------------ #
+ t0 = time.time()
+ sample_dataset = base_dataset.set_task(
+ EEGEventsTUEV(
+ resample_rate=200,
+ normalization="95th_percentile",
+ compute_stft=True,
+ )
+ )
+ print(f"Task set in {time.time() - t0:.1f}s | total samples: {len(sample_dataset)}")
+
+ # ------------------------------------------------------------------ #
+ # STEP 3: Extract fixed test set (TUH eval partition)
+ # ------------------------------------------------------------------ #
+ _, _, _, test_ds = split_by_patient_conformal_tuh(
+ dataset=sample_dataset,
+ ratios=[0.6, 0.2, 0.2],
+ seed=42,
+ )
+ test_loader = get_dataloader(test_ds, batch_size=32, shuffle=False)
+ print(f"Test set size: {len(test_ds)}")
+
+ # ------------------------------------------------------------------ #
+ # STEP 4: Load TFMTokenizer with pre-trained weights (no training)
+ # ------------------------------------------------------------------ #
+ model = TFMTokenizer(dataset=sample_dataset).to(device)
+ model.load_pretrained_weights(
+ tokenizer_checkpoint_path=TOKENIZER_WEIGHTS,
+ classifier_checkpoint_path=classifier_weights,
+ )
+
+ # ------------------------------------------------------------------ #
+ # STEP 5: Evaluate
+ # ------------------------------------------------------------------ #
+ trainer = Trainer(
+ model=model,
+ device=device,
+ metrics=["accuracy", "f1_weighted", "f1_macro"],
+ enable_logging=False,
+ )
+ t0 = time.time()
+ results = trainer.evaluate(test_loader)
+ print(f"\nEval time: {time.time() - t0:.1f}s")
+ print("\n=== Test Results ===")
+ for metric, value in results.items():
+ print(f" {metric}: {value:.4f}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/conformal_eeg/tuab_conventional_conformal.py b/examples/conformal_eeg/tuab_conventional_conformal.py
new file mode 100644
index 000000000..7789b83e7
--- /dev/null
+++ b/examples/conformal_eeg/tuab_conventional_conformal.py
@@ -0,0 +1,444 @@
+"""Conventional Conformal Prediction (LABEL) on TUAB Abnormal EEG Detection using ContraWR.
+
+This script:
+1) Loads the TUAB dataset and applies the EEGAbnormalTUAB task (once, shared across all seeds).
+2) Extracts the fixed test set (TUH eval partition — never changes across seeds).
+3) For each seed: splits the TUH train partition into train/val/cal, trains ContraWR,
+ calibrates a LABEL predictor, and evaluates on the fixed test set.
+4) Reports per-run results and mean ± std summary across all seeds.
+
+Single-seed usage (from repo root):
+ python examples/conformal_eeg/tuab_conventional_conformal.py --root downloads/tuab/v3.0.0/edf
+
+Multi-seed usage (recommended for papers):
+ python examples/conformal_eeg/tuab_conventional_conformal.py \\
+ --root downloads/tuab/v3.0.0/edf --n-seeds 5 --seed 42 --alpha 0.1 \\
+ --log-file tuab_conventional_alpha0.1_5seeds.log
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import random
+import sys
+from pathlib import Path
+
+import numpy as np
+import torch
+
+
+class _Tee:
+ """Writes to both a stream and a file simultaneously."""
+
+ def __init__(self, stream, file):
+ self._stream = stream
+ self._file = file
+
+ def write(self, data):
+ self._stream.write(data)
+ self._file.write(data)
+ self._file.flush()
+
+ def flush(self):
+ self._stream.flush()
+ self._file.flush()
+
+
+from pyhealth.calib.predictionset import LABEL
+from pyhealth.datasets import TUABDataset, get_dataloader, split_by_patient_conformal_tuh, split_by_sample_conformal_tuh, split_by_sample_conformal
+from pyhealth.models import ContraWR, TFMTokenizer
+from pyhealth.tasks import EEGAbnormalTUAB
+from pyhealth.trainer import Trainer, get_metrics_fn
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="Conventional conformal prediction (LABEL) on TUAB abnormal EEG detection using ContraWR."
+ )
+ parser.add_argument(
+ "--root",
+ type=str,
+ default="/srv/local/data/TUH/tuh_eeg_abnormal/v3.0.0/edf",
+ help="Path to TUAB edf/ folder.",
+ )
+ parser.add_argument("--subset", type=str, default="both", choices=["train", "eval", "both"])
+ parser.add_argument(
+ "--seed",
+ type=int,
+ default=42,
+ help="Base seed. With --n-seeds N, runs seeds seed, seed+1, ..., seed+N-1.",
+ )
+ parser.add_argument("--batch-size", type=int, default=32)
+ parser.add_argument("--epochs", type=int, default=20)
+ parser.add_argument(
+ "--alpha", type=float, default=0.1,
+ help="Miscoverage rate (e.g., 0.1 => 90% target coverage).",
+ )
+ parser.add_argument(
+ "--alphas", type=str, default=None,
+ help="Comma-separated miscoverage rates, e.g. '0.2,0.1,0.05,0.01'. Overrides --alpha.",
+ )
+ parser.add_argument(
+ "--ratios",
+ type=float,
+ nargs=3,
+ default=(0.6, 0.2, 0.2),
+ metavar=("TRAIN", "VAL", "CAL"),
+ help="Ratios for splitting the TUH train partition into train/val/cal. "
+ "Must sum to 1.0. Test is fixed as the TUH eval partition.",
+ )
+ parser.add_argument("--n-fft", type=int, default=128, help="STFT FFT size used by ContraWR.")
+ parser.add_argument(
+ "--model", type=str, default="contrawr", choices=["contrawr", "tfm"],
+ help="Backbone model: 'contrawr' (default) or 'tfm' (TFMTokenizer).",
+ )
+ parser.add_argument(
+ "--device", type=str, default=None,
+ help="Device string, e.g. 'cuda:0' or 'cpu'. Defaults to auto-detect.",
+ )
+ parser.add_argument(
+ "--n-seeds",
+ type=int,
+ default=1,
+ help="Number of seeds to run sequentially for mean±std reporting. "
+ "Seeds are seed, seed+1, ..., seed+n_seeds-1.",
+ )
+ parser.add_argument(
+ "--seeds",
+ type=str,
+ default=None,
+ help="Explicit comma-separated seeds, e.g. '42,43,44,45,46'. "
+ "Overrides --seed and --n-seeds.",
+ )
+ parser.add_argument(
+ "--log-file", type=str, default=None,
+ help="Path to log file. Stdout and stderr are teed to this file.",
+ )
+ parser.add_argument(
+ "--quick-test",
+ action="store_true",
+ help="Smoke test: dev=True, max 2000 samples, 2 epochs.",
+ )
+ parser.add_argument(
+ "--weights-dir",
+ type=str,
+ default="/shared/eng/conformal_eeg",
+ help="Root folder of TFM classifier checkpoints (only with --model tfm). "
+ "If the directory contains tfm_encoder_best_model_tuab.pth directly, "
+ "that single checkpoint is used for all seeds (PI TUAB setup). "
+ "Otherwise expects per-seed subdirs {base}_1..N/best_model.pth.",
+ )
+ parser.add_argument(
+ "--tokenizer-weights",
+ type=str,
+ default="/shared/eng/conformal_eeg/tfm_tokenizer_tuab.pth",
+ help="Path to the pre-trained TFM tokenizer weights (only with --model tfm).",
+ )
+ parser.add_argument(
+ "--split-type",
+ type=str,
+ default="patient",
+ choices=["patient", "sample"],
+ help="Split strategy: 'patient' (default, patient-level, no leakage) or "
+ "'sample' (original sample-level, for comparison).",
+ )
+ return parser.parse_args()
+
+
+def _do_split(dataset, ratios, seed, split_type):
+ """Dispatch to the correct TUH split function based on split_type."""
+ if split_type == "patient":
+ return split_by_patient_conformal_tuh(dataset=dataset, ratios=list(ratios), seed=seed)
+ else:
+ return split_by_sample_conformal_tuh(dataset=dataset, ratios=list(ratios), seed=seed)
+
+
+def _load_tfm_weights(model, args, run_idx: int) -> None:
+ """Load pre-trained tokenizer + fine-tuned classifier for run_idx (0-based).
+
+ Supports two layouts:
+ - Single classifier (PI TUAB setup): weights_dir/tfm_encoder_best_model_tuab.pth
+ Used for all seeds — only the data split varies across runs.
+ - Per-seed subdirs: weights_dir/{base}_{run_idx+1}/best_model.pth
+ """
+ single = os.path.join(args.weights_dir, "tfm_encoder_best_model_tuab.pth")
+ if os.path.isfile(single):
+ classifier_path = single
+ else:
+ base = os.path.basename(args.weights_dir)
+ classifier_path = os.path.join(args.weights_dir, f"{base}_{run_idx + 1}", "best_model.pth")
+ print(f" Loading TFM weights (run {run_idx + 1}): {classifier_path}")
+ model.load_pretrained_weights(
+ tokenizer_checkpoint_path=args.tokenizer_weights,
+ classifier_checkpoint_path=classifier_path,
+ )
+
+
+def set_seed(seed: int) -> None:
+ random.seed(seed)
+ np.random.seed(seed)
+ torch.manual_seed(seed)
+ if torch.cuda.is_available():
+ torch.cuda.manual_seed_all(seed)
+
+
+def _run_one_seed(
+ args,
+ sample_dataset,
+ test_ds,
+ test_loader,
+ device: str,
+ epochs: int,
+ run_seed: int,
+ alphas: list,
+ run_idx: int = 0,
+) -> dict:
+ """Train model + calibrate LABEL for one seed across all alphas.
+
+ Training and base-model inference are done once; calibration loops over alphas (fast).
+
+ Returns {alpha: metrics_dict} where metrics_dict has keys:
+ accuracy, roc_auc_weighted_ovr, f1_weighted, coverage, miscoverage, avg_set_size
+ """
+ set_seed(run_seed)
+
+ train_ds, val_ds, cal_ds, _ = _do_split(
+ sample_dataset, args.ratios, run_seed, args.split_type
+ )
+ print(f" Split — Train: {len(train_ds)}, Val: {len(val_ds)}, "
+ f"Cal: {len(cal_ds)}, Test: {len(test_ds)} (fixed)")
+
+ train_loader = get_dataloader(train_ds, batch_size=args.batch_size, shuffle=True)
+ val_loader = (
+ get_dataloader(val_ds, batch_size=args.batch_size, shuffle=False)
+ if len(val_ds) else None
+ )
+
+ if args.model == "tfm":
+ model = TFMTokenizer(dataset=sample_dataset).to(device)
+ _load_tfm_weights(model, args, run_idx)
+ else:
+ model = ContraWR(dataset=sample_dataset, n_fft=args.n_fft).to(device)
+ print(" Training ContraWR...")
+ trainer_tmp = Trainer(model=model, device=device, enable_logging=False)
+ trainer_tmp.train(
+ train_dataloader=train_loader,
+ val_dataloader=val_loader,
+ epochs=epochs,
+ monitor="accuracy" if val_loader is not None else None,
+ )
+ trainer = Trainer(model=model, device=device, enable_logging=False)
+
+ # Base model metrics — computed once, shared across all alphas
+ y_true_base, y_prob_base, _ = trainer.inference(test_loader)
+ base_metrics = get_metrics_fn("multiclass")(
+ y_true_base, y_prob_base, metrics=["accuracy", "roc_auc_weighted_ovr", "f1_weighted"]
+ )
+
+ # Calibration + evaluation — fast; loop over every alpha
+ results = {}
+ for alpha in alphas:
+ print(f" Calibrating LABEL predictor (alpha={alpha})...")
+ label_predictor = LABEL(model=model, alpha=float(alpha))
+ label_predictor.calibrate(cal_dataset=cal_ds)
+
+ y_true, y_prob, _, extra = Trainer(model=label_predictor).inference(
+ test_loader, additional_outputs=["y_predset"]
+ )
+ conf_metrics = get_metrics_fn("multiclass")(
+ y_true, y_prob,
+ metrics=["accuracy", "miscoverage_ps"],
+ y_predset=extra["y_predset"],
+ )
+
+ predset = extra["y_predset"]
+ predset_t = torch.tensor(predset) if isinstance(predset, np.ndarray) else predset
+ avg_set_size = predset_t.float().sum(dim=1).mean().item()
+
+ miscoverage = conf_metrics["miscoverage_ps"]
+ if isinstance(miscoverage, np.ndarray):
+ miscoverage = float(miscoverage.item() if miscoverage.size == 1 else miscoverage.mean())
+ else:
+ miscoverage = float(miscoverage)
+
+ results[alpha] = {
+ "accuracy": float(base_metrics["accuracy"]),
+ "roc_auc_weighted_ovr": float(base_metrics["roc_auc_weighted_ovr"]),
+ "f1_weighted": float(base_metrics["f1_weighted"]),
+ "coverage": 1.0 - miscoverage,
+ "miscoverage": miscoverage,
+ "avg_set_size": avg_set_size,
+ }
+ return results
+
+
+def _print_single_run_results(metrics: dict, alpha: float) -> None:
+ print("\nLABEL Results:")
+ print(f" Accuracy: {metrics['accuracy']:.4f}")
+ print(f" ROC-AUC: {metrics['roc_auc_weighted_ovr']:.4f}")
+ print(f" F1: {metrics['f1_weighted']:.4f}")
+ print(f" Empirical coverage: {metrics['coverage']:.4f}")
+ print(f" Empirical miscoverage: {metrics['miscoverage']:.4f}")
+ print(f" Average set size: {metrics['avg_set_size']:.2f}")
+ print(f" Target coverage: {1 - alpha:.0%} (alpha={alpha})")
+
+
+def _print_multi_seed_summary(
+ all_metrics: list, run_seeds: list, alpha: float, n_test: int
+) -> None:
+ accs = np.array([m["accuracy"] for m in all_metrics])
+ roc_aucs = np.array([m["roc_auc_weighted_ovr"] for m in all_metrics])
+ f1s = np.array([m["f1_weighted"] for m in all_metrics])
+ coverages = np.array([m["coverage"] for m in all_metrics])
+ miscovs = np.array([m["miscoverage"] for m in all_metrics])
+ set_sizes = np.array([m["avg_set_size"] for m in all_metrics])
+ n_runs = len(all_metrics)
+
+ print("\n" + "=" * 80)
+ print(f"Per-run results — alpha={alpha} (LABEL, fixed test set = TUH eval partition)")
+ print("=" * 80)
+ print(f" {'Run':<4} {'Seed':<6} {'Accuracy':<10} {'ROC-AUC':<10} {'F1':<8} "
+ f"{'Coverage':<10} {'Miscoverage':<12} {'Avg set size':<12}")
+ print(" " + "-" * 76)
+ for i in range(n_runs):
+ m = all_metrics[i]
+ print(f" {i+1:<4} {run_seeds[i]:<6} {m['accuracy']:<10.4f} "
+ f"{m['roc_auc_weighted_ovr']:<10.4f} {m['f1_weighted']:<8.4f} {m['coverage']:<10.4f} "
+ f"{m['miscoverage']:<12.4f} {m['avg_set_size']:<12.2f}")
+
+ print("\n" + "=" * 80)
+ print(f"Summary — alpha={alpha} (mean \u00b1 std over {n_runs} runs, fixed test set)")
+ print(" Method: LABEL")
+ print("=" * 80)
+ print(f" Accuracy: {accs.mean():.4f} \u00b1 {accs.std():.4f}")
+ print(f" ROC-AUC: {roc_aucs.mean():.4f} \u00b1 {roc_aucs.std():.4f}")
+ print(f" F1: {f1s.mean():.4f} \u00b1 {f1s.std():.4f}")
+ print(f" Empirical coverage: {coverages.mean():.4f} \u00b1 {coverages.std():.4f}")
+ print(f" Empirical miscoverage: {miscovs.mean():.4f} \u00b1 {miscovs.std():.4f}")
+ print(f" Average set size: {set_sizes.mean():.2f} \u00b1 {set_sizes.std():.2f}")
+ print(f" Target coverage: {1 - alpha:.0%} (alpha={alpha})")
+ print(f" Test set size: {n_test} (fixed across runs)")
+ print(f" Run seeds: {run_seeds}")
+ print("\n--- Min / Max (across runs) ---")
+ print(f" Coverage: [{coverages.min():.4f}, {coverages.max():.4f}]")
+ print(f" Set size: [{set_sizes.min():.2f}, {set_sizes.max():.2f}]")
+ print(f" Accuracy: [{accs.min():.4f}, {accs.max():.4f}]")
+ print(f" ROC-AUC: [{roc_aucs.min():.4f}, {roc_aucs.max():.4f}]")
+
+
+def _main(args: argparse.Namespace) -> None:
+ device = args.device or ("cuda:0" if torch.cuda.is_available() else "cpu")
+ root = Path(args.root)
+ if not root.exists():
+ raise FileNotFoundError(
+ f"TUAB root not found: {root}. "
+ "Pass --root to point to your downloaded TUAB edf/ directory."
+ )
+
+ epochs = 2 if args.quick_test else args.epochs
+ quick_test_max_samples = 2000
+ if args.quick_test:
+ print("*** QUICK TEST MODE (dev=True, 2 epochs, max 2000 samples) ***")
+
+ # -------------------------------------------------------------------------
+ # STEP 1: Load dataset ONCE — shared across all seeds
+ # -------------------------------------------------------------------------
+ print("=" * 80)
+ print("STEP 1: Load TUAB + build task dataset (shared across all seeds)")
+ print("=" * 80)
+ dataset = TUABDataset(root=str(root), subset=args.subset, dev=args.quick_test)
+ sample_dataset = dataset.set_task(EEGAbnormalTUAB(normalization="95th_percentile"), num_workers=16)
+ if args.quick_test and len(sample_dataset) > quick_test_max_samples:
+ sample_dataset = sample_dataset.subset(range(quick_test_max_samples))
+ print(f"Capped to {quick_test_max_samples} samples for quick-test.")
+ print(f"Task samples: {len(sample_dataset)}")
+ print(f"Input schema: {sample_dataset.input_schema}")
+ print(f"Output schema: {sample_dataset.output_schema}")
+ if len(sample_dataset) == 0:
+ raise RuntimeError("No samples produced. Verify TUAB root/subset/task.")
+
+ # -------------------------------------------------------------------------
+ # STEP 2: Extract the fixed test set ONCE
+ # -------------------------------------------------------------------------
+ print("\n" + "=" * 80)
+ print("STEP 2: Extract fixed test set (TUH eval partition — same for all seeds)")
+ print("=" * 80)
+ _, _, _, test_ds = _do_split(
+ sample_dataset, args.ratios, args.seed, args.split_type
+ )
+ if len(test_ds) == 0 and args.quick_test:
+ print(" [quick-test] TUH eval partition empty in dev mode — using random 20% as test set.")
+ _, _, _, test_ds = split_by_sample_conformal(
+ dataset=sample_dataset, ratios=[0.6, 0.1, 0.1, 0.2], seed=args.seed
+ )
+ test_loader = get_dataloader(test_ds, batch_size=args.batch_size, shuffle=False)
+ print(f"Test: {len(test_ds)} (fixed)")
+
+ # -------------------------------------------------------------------------
+ # Determine run seeds
+ # -------------------------------------------------------------------------
+ if args.seeds is not None:
+ run_seeds = [int(s.strip()) for s in args.seeds.split(",")]
+ else:
+ run_seeds = [args.seed + i for i in range(args.n_seeds)]
+
+ alphas = [float(a.strip()) for a in args.alphas.split(",")] if args.alphas else [args.alpha]
+
+ use_multi_seed = len(run_seeds) > 1
+ print(f"\nRun config: {'multi-seed (' + str(len(run_seeds)) + ' runs)' if use_multi_seed else 'single run'}")
+ print(f"Seeds: {run_seeds}, alphas={alphas}")
+
+ # -------------------------------------------------------------------------
+ # STEP 3+: Train once per seed; calibrate for every alpha (fast)
+ # -------------------------------------------------------------------------
+ all_metrics = {alpha: [] for alpha in alphas}
+ for run_i, run_seed in enumerate(run_seeds):
+ print("\n" + "=" * 80)
+ if use_multi_seed:
+ print(f"Run {run_i + 1} / {len(run_seeds)} (seed={run_seed})")
+ else:
+ print(f"STEP 3–4: Train + Conformal Calibration (seed={run_seed})")
+ print("=" * 80)
+
+ seed_results = _run_one_seed(
+ args, sample_dataset, test_ds, test_loader, device, epochs, run_seed, alphas,
+ run_idx=run_i,
+ )
+ for alpha in alphas:
+ all_metrics[alpha].append(seed_results[alpha])
+
+ if use_multi_seed:
+ m = seed_results[alphas[0]]
+ print(f" [Run {run_i + 1} result (alpha={alphas[0]})] "
+ f"acc={m['accuracy']:.4f}, roc_auc={m['roc_auc_weighted_ovr']:.4f}, "
+ f"cov={m['coverage']:.4f}, set_size={m['avg_set_size']:.2f}")
+
+ for alpha in alphas:
+ if not use_multi_seed:
+ _print_single_run_results(all_metrics[alpha][0], alpha)
+ else:
+ _print_multi_seed_summary(all_metrics[alpha], run_seeds, alpha, len(test_ds))
+
+
+def main() -> None:
+ args = parse_args()
+
+ orig_stdout, orig_stderr = sys.stdout, sys.stderr
+ log_file = None
+ if args.log_file:
+ log_file = open(args.log_file, "w", encoding="utf-8")
+ sys.stdout = _Tee(orig_stdout, log_file)
+ sys.stderr = _Tee(orig_stderr, log_file)
+
+ try:
+ _main(args)
+ finally:
+ if log_file is not None:
+ sys.stdout = orig_stdout
+ sys.stderr = orig_stderr
+ log_file.close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/conformal_eeg/tuab_covariate_shift_conformal.py b/examples/conformal_eeg/tuab_covariate_shift_conformal.py
new file mode 100644
index 000000000..11460d139
--- /dev/null
+++ b/examples/conformal_eeg/tuab_covariate_shift_conformal.py
@@ -0,0 +1,460 @@
+"""Covariate-Shift Adaptive Conformal Prediction (CovariateLabel) on TUAB Abnormal EEG Detection using ContraWR.
+
+This script:
+1) Loads the TUAB dataset and applies the EEGAbnormalTUAB task (once, shared across all seeds).
+2) Extracts the fixed test set (TUH eval partition — never changes across seeds).
+3) For each seed: splits the TUH train partition into train/val/cal, trains ContraWR,
+ extracts cal and test embeddings, calibrates a CovariateLabel predictor, and
+ evaluates on the fixed test set.
+4) Reports per-run results and mean ± std summary across all seeds.
+
+Single-seed usage (from repo root):
+ python examples/conformal_eeg/tuab_covariate_shift_conformal.py --root downloads/tuab/v3.0.0/edf
+
+Multi-seed usage (recommended for papers):
+ python examples/conformal_eeg/tuab_covariate_shift_conformal.py \\
+ --root downloads/tuab/v3.0.0/edf --n-seeds 5 --seed 42 --alpha 0.1 \\
+ --log-file tuab_covariate_alpha0.1_5seeds.log
+
+Notes:
+- CovariateLabel requires access to test embeddings to estimate density ratios.
+- Test embeddings are recomputed each seed since the model changes.
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import random
+import sys
+from pathlib import Path
+
+import numpy as np
+import torch
+
+
+class _Tee:
+ """Writes to both a stream and a file simultaneously."""
+
+ def __init__(self, stream, file):
+ self._stream = stream
+ self._file = file
+
+ def write(self, data):
+ self._stream.write(data)
+ self._file.write(data)
+ self._file.flush()
+
+ def flush(self):
+ self._stream.flush()
+ self._file.flush()
+
+
+from pyhealth.calib.predictionset.covariate import CovariateLabel
+from pyhealth.calib.utils import extract_embeddings
+from pyhealth.datasets import TUABDataset, get_dataloader, split_by_patient_conformal_tuh, split_by_sample_conformal_tuh, split_by_sample_conformal
+from pyhealth.models import ContraWR, TFMTokenizer
+from pyhealth.tasks import EEGAbnormalTUAB
+from pyhealth.trainer import Trainer, get_metrics_fn
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="Covariate-shift adaptive conformal prediction (CovariateLabel) on TUAB abnormal EEG detection using ContraWR."
+ )
+ parser.add_argument(
+ "--root",
+ type=str,
+ default="/srv/local/data/TUH/tuh_eeg_abnormal/v3.0.0/edf",
+ help="Path to TUAB edf/ folder.",
+ )
+ parser.add_argument("--subset", type=str, default="both", choices=["train", "eval", "both"])
+ parser.add_argument(
+ "--seed",
+ type=int,
+ default=42,
+ help="Base seed. With --n-seeds N, runs seeds seed, seed+1, ..., seed+N-1.",
+ )
+ parser.add_argument("--batch-size", type=int, default=32)
+ parser.add_argument("--epochs", type=int, default=20)
+ parser.add_argument(
+ "--alpha", type=float, default=0.1,
+ help="Miscoverage rate (e.g., 0.1 => 90% target coverage).",
+ )
+ parser.add_argument(
+ "--alphas", type=str, default=None,
+ help="Comma-separated miscoverage rates, e.g. '0.2,0.1,0.05,0.01'. Overrides --alpha.",
+ )
+ parser.add_argument(
+ "--ratios",
+ type=float,
+ nargs=3,
+ default=(0.6, 0.2, 0.2),
+ metavar=("TRAIN", "VAL", "CAL"),
+ help="Ratios for splitting the TUH train partition into train/val/cal. "
+ "Must sum to 1.0. Test is fixed as the TUH eval partition.",
+ )
+ parser.add_argument("--n-fft", type=int, default=128, help="STFT FFT size used by ContraWR.")
+ parser.add_argument(
+ "--model", type=str, default="contrawr", choices=["contrawr", "tfm"],
+ help="Backbone model: 'contrawr' (default) or 'tfm' (TFMTokenizer).",
+ )
+ parser.add_argument(
+ "--device", type=str, default=None,
+ help="Device string, e.g. 'cuda:0' or 'cpu'. Defaults to auto-detect.",
+ )
+ parser.add_argument(
+ "--n-seeds",
+ type=int,
+ default=1,
+ help="Number of seeds to run sequentially for mean±std reporting. "
+ "Seeds are seed, seed+1, ..., seed+n_seeds-1.",
+ )
+ parser.add_argument(
+ "--seeds",
+ type=str,
+ default=None,
+ help="Explicit comma-separated seeds, e.g. '42,43,44,45,46'. "
+ "Overrides --seed and --n-seeds.",
+ )
+ parser.add_argument(
+ "--log-file", type=str, default=None,
+ help="Path to log file. Stdout and stderr are teed to this file.",
+ )
+ parser.add_argument(
+ "--quick-test",
+ action="store_true",
+ help="Smoke test: dev=True, max 2000 samples, 2 epochs.",
+ )
+ parser.add_argument(
+ "--weights-dir",
+ type=str,
+ default="/shared/eng/conformal_eeg",
+ help="Root folder of TFM classifier checkpoints (only with --model tfm). "
+ "If the directory contains tfm_encoder_best_model_tuab.pth directly, "
+ "that single checkpoint is used for all seeds (PI TUAB setup). "
+ "Otherwise expects per-seed subdirs {base}_1..N/best_model.pth.",
+ )
+ parser.add_argument(
+ "--tokenizer-weights",
+ type=str,
+ default="/shared/eng/conformal_eeg/tfm_tokenizer_tuab.pth",
+ help="Path to the pre-trained TFM tokenizer weights (only with --model tfm).",
+ )
+ parser.add_argument(
+ "--split-type",
+ type=str,
+ default="patient",
+ choices=["patient", "sample"],
+ help="Split strategy: 'patient' (default, patient-level, no leakage) or "
+ "'sample' (original sample-level, for comparison).",
+ )
+ return parser.parse_args()
+
+
+def _do_split(dataset, ratios, seed, split_type):
+ """Dispatch to the correct TUH split function based on split_type."""
+ if split_type == "patient":
+ return split_by_patient_conformal_tuh(dataset=dataset, ratios=list(ratios), seed=seed)
+ else:
+ return split_by_sample_conformal_tuh(dataset=dataset, ratios=list(ratios), seed=seed)
+
+
+def _load_tfm_weights(model, args, run_idx: int) -> None:
+ """Load pre-trained tokenizer + fine-tuned classifier for run_idx (0-based).
+
+ Supports two layouts:
+ - Single classifier (PI TUAB setup): weights_dir/tfm_encoder_best_model_tuab.pth
+ Used for all seeds — only the data split varies across runs.
+ - Per-seed subdirs: weights_dir/{base}_{run_idx+1}/best_model.pth
+ """
+ single = os.path.join(args.weights_dir, "tfm_encoder_best_model_tuab.pth")
+ if os.path.isfile(single):
+ classifier_path = single
+ else:
+ base = os.path.basename(args.weights_dir)
+ classifier_path = os.path.join(args.weights_dir, f"{base}_{run_idx + 1}", "best_model.pth")
+ print(f" Loading TFM weights (run {run_idx + 1}): {classifier_path}")
+ model.load_pretrained_weights(
+ tokenizer_checkpoint_path=args.tokenizer_weights,
+ classifier_checkpoint_path=classifier_path,
+ )
+
+
+def set_seed(seed: int) -> None:
+ random.seed(seed)
+ np.random.seed(seed)
+ torch.manual_seed(seed)
+ if torch.cuda.is_available():
+ torch.cuda.manual_seed_all(seed)
+
+
+def _run_one_seed(
+ args,
+ sample_dataset,
+ test_ds,
+ test_loader,
+ device: str,
+ epochs: int,
+ run_seed: int,
+ alphas: list,
+ run_idx: int = 0,
+) -> dict:
+ """Train model + calibrate CovariateLabel for one seed across all alphas.
+
+ Training, embedding extraction, and base inference are done once; calibration
+ loops over alphas (fast — only likelihood-ratio weights/threshold recomputed).
+
+ Returns {alpha: metrics_dict} where metrics_dict has keys:
+ accuracy, roc_auc_weighted_ovr, f1_weighted, coverage, miscoverage, avg_set_size
+ """
+ set_seed(run_seed)
+
+ train_ds, val_ds, cal_ds, _ = _do_split(
+ sample_dataset, args.ratios, run_seed, args.split_type
+ )
+ print(f" Split — Train: {len(train_ds)}, Val: {len(val_ds)}, "
+ f"Cal: {len(cal_ds)}, Test: {len(test_ds)} (fixed)")
+
+ train_loader = get_dataloader(train_ds, batch_size=args.batch_size, shuffle=True)
+ val_loader = (
+ get_dataloader(val_ds, batch_size=args.batch_size, shuffle=False)
+ if len(val_ds) else None
+ )
+
+ if args.model == "tfm":
+ model = TFMTokenizer(dataset=sample_dataset).to(device)
+ _load_tfm_weights(model, args, run_idx)
+ else:
+ model = ContraWR(dataset=sample_dataset, n_fft=args.n_fft).to(device)
+ print(" Training ContraWR...")
+ trainer_tmp = Trainer(model=model, device=device, enable_logging=False)
+ trainer_tmp.train(
+ train_dataloader=train_loader,
+ val_dataloader=val_loader,
+ epochs=epochs,
+ monitor="accuracy" if val_loader is not None else None,
+ )
+ trainer = Trainer(model=model, device=device, enable_logging=False)
+
+ # Base model metrics — computed once, shared across all alphas
+ y_true_base, y_prob_base, _ = trainer.inference(test_loader)
+ base_metrics = get_metrics_fn("multiclass")(
+ y_true_base, y_prob_base, metrics=["accuracy", "roc_auc_weighted_ovr", "f1_weighted"]
+ )
+
+ # Extract embeddings once — reused for every alpha
+ print(" Extracting embeddings for calibration and test splits...")
+ cal_embeddings = extract_embeddings(model, cal_ds, batch_size=args.batch_size, device=device)
+ test_embeddings = extract_embeddings(model, test_ds, batch_size=args.batch_size, device=device)
+
+ # Calibration + evaluation — fast; loop over every alpha
+ results = {}
+ for alpha in alphas:
+ print(f" Calibrating CovariateLabel predictor (alpha={alpha})...")
+ cov_predictor = CovariateLabel(model=model, alpha=float(alpha))
+ cov_predictor.calibrate(
+ cal_dataset=cal_ds,
+ cal_embeddings=cal_embeddings,
+ test_embeddings=test_embeddings,
+ )
+
+ y_true, y_prob, _, extra = Trainer(model=cov_predictor).inference(
+ test_loader, additional_outputs=["y_predset"]
+ )
+ conf_metrics = get_metrics_fn("multiclass")(
+ y_true, y_prob,
+ metrics=["accuracy", "miscoverage_ps"],
+ y_predset=extra["y_predset"],
+ )
+
+ predset = extra["y_predset"]
+ predset_t = torch.tensor(predset) if isinstance(predset, np.ndarray) else predset
+ avg_set_size = predset_t.float().sum(dim=1).mean().item()
+
+ miscoverage = conf_metrics["miscoverage_ps"]
+ if isinstance(miscoverage, np.ndarray):
+ miscoverage = float(miscoverage.item() if miscoverage.size == 1 else miscoverage.mean())
+ else:
+ miscoverage = float(miscoverage)
+
+ results[alpha] = {
+ "accuracy": float(base_metrics["accuracy"]),
+ "roc_auc_weighted_ovr": float(base_metrics["roc_auc_weighted_ovr"]),
+ "f1_weighted": float(base_metrics["f1_weighted"]),
+ "coverage": 1.0 - miscoverage,
+ "miscoverage": miscoverage,
+ "avg_set_size": avg_set_size,
+ }
+ return results
+
+
+def _print_single_run_results(metrics: dict, alpha: float) -> None:
+ print("\nCovariateLabel Results:")
+ print(f" Accuracy: {metrics['accuracy']:.4f}")
+ print(f" ROC-AUC: {metrics['roc_auc_weighted_ovr']:.4f}")
+ print(f" F1: {metrics['f1_weighted']:.4f}")
+ print(f" Empirical coverage: {metrics['coverage']:.4f}")
+ print(f" Empirical miscoverage: {metrics['miscoverage']:.4f}")
+ print(f" Average set size: {metrics['avg_set_size']:.2f}")
+ print(f" Target coverage: {1 - alpha:.0%} (alpha={alpha})")
+
+
+def _print_multi_seed_summary(
+ all_metrics: list, run_seeds: list, alpha: float, n_test: int
+) -> None:
+ accs = np.array([m["accuracy"] for m in all_metrics])
+ roc_aucs = np.array([m["roc_auc_weighted_ovr"] for m in all_metrics])
+ f1s = np.array([m["f1_weighted"] for m in all_metrics])
+ coverages = np.array([m["coverage"] for m in all_metrics])
+ miscovs = np.array([m["miscoverage"] for m in all_metrics])
+ set_sizes = np.array([m["avg_set_size"] for m in all_metrics])
+ n_runs = len(all_metrics)
+
+ print("\n" + "=" * 80)
+ print(f"Per-run results — alpha={alpha} (CovariateLabel, fixed test set = TUH eval partition)")
+ print("=" * 80)
+ print(f" {'Run':<4} {'Seed':<6} {'Accuracy':<10} {'ROC-AUC':<10} {'F1':<8} "
+ f"{'Coverage':<10} {'Miscoverage':<12} {'Avg set size':<12}")
+ print(" " + "-" * 76)
+ for i in range(n_runs):
+ m = all_metrics[i]
+ print(f" {i+1:<4} {run_seeds[i]:<6} {m['accuracy']:<10.4f} "
+ f"{m['roc_auc_weighted_ovr']:<10.4f} {m['f1_weighted']:<8.4f} {m['coverage']:<10.4f} "
+ f"{m['miscoverage']:<12.4f} {m['avg_set_size']:<12.2f}")
+
+ print("\n" + "=" * 80)
+ print(f"Summary — alpha={alpha} (mean \u00b1 std over {n_runs} runs, fixed test set)")
+ print(" Method: CovariateLabel")
+ print("=" * 80)
+ print(f" Accuracy: {accs.mean():.4f} \u00b1 {accs.std():.4f}")
+ print(f" ROC-AUC: {roc_aucs.mean():.4f} \u00b1 {roc_aucs.std():.4f}")
+ print(f" F1: {f1s.mean():.4f} \u00b1 {f1s.std():.4f}")
+ print(f" Empirical coverage: {coverages.mean():.4f} \u00b1 {coverages.std():.4f}")
+ print(f" Empirical miscoverage: {miscovs.mean():.4f} \u00b1 {miscovs.std():.4f}")
+ print(f" Average set size: {set_sizes.mean():.2f} \u00b1 {set_sizes.std():.2f}")
+ print(f" Target coverage: {1 - alpha:.0%} (alpha={alpha})")
+ print(f" Test set size: {n_test} (fixed across runs)")
+ print(f" Run seeds: {run_seeds}")
+ print("\n--- Min / Max (across runs) ---")
+ print(f" Coverage: [{coverages.min():.4f}, {coverages.max():.4f}]")
+ print(f" Set size: [{set_sizes.min():.2f}, {set_sizes.max():.2f}]")
+ print(f" Accuracy: [{accs.min():.4f}, {accs.max():.4f}]")
+ print(f" ROC-AUC: [{roc_aucs.min():.4f}, {roc_aucs.max():.4f}]")
+
+
+def _main(args: argparse.Namespace) -> None:
+ device = args.device or ("cuda:0" if torch.cuda.is_available() else "cpu")
+ root = Path(args.root)
+ if not root.exists():
+ raise FileNotFoundError(
+ f"TUAB root not found: {root}. "
+ "Pass --root to point to your downloaded TUAB edf/ directory."
+ )
+
+ epochs = 2 if args.quick_test else args.epochs
+ quick_test_max_samples = 2000
+ if args.quick_test:
+ print("*** QUICK TEST MODE (dev=True, 2 epochs, max 2000 samples) ***")
+
+ # -------------------------------------------------------------------------
+ # STEP 1: Load dataset ONCE — shared across all seeds
+ # -------------------------------------------------------------------------
+ print("=" * 80)
+ print("STEP 1: Load TUAB + build task dataset (shared across all seeds)")
+ print("=" * 80)
+ dataset = TUABDataset(root=str(root), subset=args.subset, dev=args.quick_test)
+ sample_dataset = dataset.set_task(EEGAbnormalTUAB(normalization="95th_percentile"), num_workers=16)
+ if args.quick_test and len(sample_dataset) > quick_test_max_samples:
+ sample_dataset = sample_dataset.subset(range(quick_test_max_samples))
+ print(f"Capped to {quick_test_max_samples} samples for quick-test.")
+ print(f"Task samples: {len(sample_dataset)}")
+ print(f"Input schema: {sample_dataset.input_schema}")
+ print(f"Output schema: {sample_dataset.output_schema}")
+ if len(sample_dataset) == 0:
+ raise RuntimeError("No samples produced. Verify TUAB root/subset/task.")
+
+ # -------------------------------------------------------------------------
+ # STEP 2: Extract the fixed test set ONCE
+ # -------------------------------------------------------------------------
+ print("\n" + "=" * 80)
+ print("STEP 2: Extract fixed test set (TUH eval partition — same for all seeds)")
+ print("=" * 80)
+ _, _, _, test_ds = _do_split(
+ sample_dataset, args.ratios, args.seed, args.split_type
+ )
+ if len(test_ds) == 0 and args.quick_test:
+ print(" [quick-test] TUH eval partition empty in dev mode — using random 20% as test set.")
+ _, _, _, test_ds = split_by_sample_conformal(
+ dataset=sample_dataset, ratios=[0.6, 0.1, 0.1, 0.2], seed=args.seed
+ )
+ test_loader = get_dataloader(test_ds, batch_size=args.batch_size, shuffle=False)
+ print(f"Test: {len(test_ds)} (fixed)")
+
+ # -------------------------------------------------------------------------
+ # Determine run seeds
+ # -------------------------------------------------------------------------
+ if args.seeds is not None:
+ run_seeds = [int(s.strip()) for s in args.seeds.split(",")]
+ else:
+ run_seeds = [args.seed + i for i in range(args.n_seeds)]
+
+ alphas = [float(a.strip()) for a in args.alphas.split(",")] if args.alphas else [args.alpha]
+
+ use_multi_seed = len(run_seeds) > 1
+ print(f"\nRun config: {'multi-seed (' + str(len(run_seeds)) + ' runs)' if use_multi_seed else 'single run'}")
+ print(f"Seeds: {run_seeds}, alphas={alphas}")
+
+ # -------------------------------------------------------------------------
+ # STEP 3+: Train once per seed; calibrate for every alpha (fast)
+ # -------------------------------------------------------------------------
+ all_metrics = {alpha: [] for alpha in alphas}
+ for run_i, run_seed in enumerate(run_seeds):
+ print("\n" + "=" * 80)
+ if use_multi_seed:
+ print(f"Run {run_i + 1} / {len(run_seeds)} (seed={run_seed})")
+ else:
+ print(f"STEP 3–4: Train + Conformal Calibration (seed={run_seed})")
+ print("=" * 80)
+
+ seed_results = _run_one_seed(
+ args, sample_dataset, test_ds, test_loader, device, epochs, run_seed, alphas,
+ run_idx=run_i,
+ )
+ for alpha in alphas:
+ all_metrics[alpha].append(seed_results[alpha])
+
+ if use_multi_seed:
+ m = seed_results[alphas[0]]
+ print(f" [Run {run_i + 1} result (alpha={alphas[0]})] "
+ f"acc={m['accuracy']:.4f}, roc_auc={m['roc_auc_weighted_ovr']:.4f}, "
+ f"cov={m['coverage']:.4f}, set_size={m['avg_set_size']:.2f}")
+
+ for alpha in alphas:
+ if not use_multi_seed:
+ _print_single_run_results(all_metrics[alpha][0], alpha)
+ else:
+ _print_multi_seed_summary(all_metrics[alpha], run_seeds, alpha, len(test_ds))
+
+
+def main() -> None:
+ args = parse_args()
+
+ orig_stdout, orig_stderr = sys.stdout, sys.stderr
+ log_file = None
+ if args.log_file:
+ log_file = open(args.log_file, "w", encoding="utf-8")
+ sys.stdout = _Tee(orig_stdout, log_file)
+ sys.stderr = _Tee(orig_stderr, log_file)
+
+ try:
+ _main(args)
+ finally:
+ if log_file is not None:
+ sys.stdout = orig_stdout
+ sys.stderr = orig_stderr
+ log_file.close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/conformal_eeg/tuab_kmeans_conformal.py b/examples/conformal_eeg/tuab_kmeans_conformal.py
new file mode 100644
index 000000000..99b9742f4
--- /dev/null
+++ b/examples/conformal_eeg/tuab_kmeans_conformal.py
@@ -0,0 +1,470 @@
+"""K-means Cluster-Based Conformal Prediction (ClusterLabel) on TUAB Abnormal EEG Detection using ContraWR.
+
+This script:
+1) Loads the TUAB dataset and applies the EEGAbnormalTUAB task (once, shared across all seeds).
+2) Extracts the fixed test set (TUH eval partition — never changes across seeds).
+3) For each seed: splits the TUH train partition into train/val/cal, trains ContraWR,
+ extracts embeddings, calibrates a ClusterLabel predictor, and evaluates on the fixed test set.
+4) Reports per-run results and mean ± std summary across all seeds.
+
+Single-seed usage (from repo root):
+ python examples/conformal_eeg/tuab_kmeans_conformal.py --root downloads/tuab/v3.0.0/edf
+
+Multi-seed usage (recommended for papers):
+ python examples/conformal_eeg/tuab_kmeans_conformal.py \\
+ --root downloads/tuab/v3.0.0/edf --n-seeds 5 --seed 42 --alpha 0.1 \\
+ --log-file tuab_kmeans_alpha0.1_5seeds.log
+
+Notes:
+- ClusterLabel uses K-means clustering on embeddings to compute cluster-specific thresholds.
+- Different K values can be tested to find the optimal cluster count.
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import random
+import sys
+from pathlib import Path
+
+import numpy as np
+import torch
+
+
+class _Tee:
+ """Writes to both a stream and a file simultaneously."""
+
+ def __init__(self, stream, file):
+ self._stream = stream
+ self._file = file
+
+ def write(self, data):
+ self._stream.write(data)
+ self._file.write(data)
+ self._file.flush()
+
+ def flush(self):
+ self._stream.flush()
+ self._file.flush()
+
+
+from pyhealth.calib.predictionset.cluster import ClusterLabel
+from pyhealth.calib.utils import extract_embeddings
+from pyhealth.datasets import TUABDataset, get_dataloader, split_by_patient_conformal_tuh, split_by_sample_conformal_tuh, split_by_sample_conformal
+from pyhealth.models import ContraWR, TFMTokenizer
+from pyhealth.tasks import EEGAbnormalTUAB
+from pyhealth.trainer import Trainer, get_metrics_fn
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="K-means cluster-based conformal prediction (ClusterLabel) on TUAB abnormal EEG detection using ContraWR."
+ )
+ parser.add_argument(
+ "--root",
+ type=str,
+ default="/srv/local/data/TUH/tuh_eeg_abnormal/v3.0.0/edf",
+ help="Path to TUAB edf/ folder.",
+ )
+ parser.add_argument("--subset", type=str, default="both", choices=["train", "eval", "both"])
+ parser.add_argument(
+ "--seed",
+ type=int,
+ default=42,
+ help="Base seed. With --n-seeds N, runs seeds seed, seed+1, ..., seed+N-1.",
+ )
+ parser.add_argument("--batch-size", type=int, default=64)
+ parser.add_argument("--epochs", type=int, default=20)
+ parser.add_argument(
+ "--alpha", type=float, default=0.1,
+ help="Miscoverage rate (e.g., 0.1 => 90% target coverage).",
+ )
+ parser.add_argument(
+ "--alphas", type=str, default=None,
+ help="Comma-separated miscoverage rates, e.g. '0.2,0.1,0.05,0.01'. Overrides --alpha.",
+ )
+ parser.add_argument(
+ "--ratios",
+ type=float,
+ nargs=3,
+ default=(0.6, 0.2, 0.2),
+ metavar=("TRAIN", "VAL", "CAL"),
+ help="Ratios for splitting the TUH train partition into train/val/cal. "
+ "Must sum to 1.0. Test is fixed as the TUH eval partition.",
+ )
+ parser.add_argument(
+ "--n-clusters", type=int, default=5,
+ help="Number of K-means clusters for cluster-specific thresholds.",
+ )
+ parser.add_argument("--n-fft", type=int, default=128, help="STFT FFT size used by ContraWR.")
+ parser.add_argument(
+ "--model", type=str, default="contrawr", choices=["contrawr", "tfm"],
+ help="Backbone model: 'contrawr' (default) or 'tfm' (TFMTokenizer).",
+ )
+ parser.add_argument(
+ "--device", type=str, default=None,
+ help="Device string, e.g. 'cuda:0' or 'cpu'. Defaults to auto-detect.",
+ )
+ parser.add_argument(
+ "--n-seeds",
+ type=int,
+ default=1,
+ help="Number of seeds to run sequentially for mean±std reporting. "
+ "Seeds are seed, seed+1, ..., seed+n_seeds-1.",
+ )
+ parser.add_argument(
+ "--seeds",
+ type=str,
+ default=None,
+ help="Explicit comma-separated seeds, e.g. '42,43,44,45,46'. "
+ "Overrides --seed and --n-seeds.",
+ )
+ parser.add_argument(
+ "--log-file", type=str, default=None,
+ help="Path to log file. Stdout and stderr are teed to this file.",
+ )
+ parser.add_argument(
+ "--quick-test",
+ action="store_true",
+ help="Smoke test: dev=True, max 2000 samples, 2 epochs.",
+ )
+ parser.add_argument(
+ "--weights-dir",
+ type=str,
+ default="/shared/eng/conformal_eeg",
+ help="Root folder of TFM classifier checkpoints (only with --model tfm). "
+ "If the directory contains tfm_encoder_best_model_tuab.pth directly, "
+ "that single checkpoint is used for all seeds (PI TUAB setup). "
+ "Otherwise expects per-seed subdirs {base}_1..N/best_model.pth.",
+ )
+ parser.add_argument(
+ "--tokenizer-weights",
+ type=str,
+ default="/shared/eng/conformal_eeg/tfm_tokenizer_tuab.pth",
+ help="Path to the pre-trained TFM tokenizer weights (only with --model tfm).",
+ )
+ parser.add_argument(
+ "--split-type",
+ type=str,
+ default="patient",
+ choices=["patient", "sample"],
+ help="Split strategy: 'patient' (default, patient-level, no leakage) or "
+ "'sample' (original sample-level, for comparison).",
+ )
+ return parser.parse_args()
+
+
+def _do_split(dataset, ratios, seed, split_type):
+ """Dispatch to the correct TUH split function based on split_type."""
+ if split_type == "patient":
+ return split_by_patient_conformal_tuh(dataset=dataset, ratios=list(ratios), seed=seed)
+ else:
+ return split_by_sample_conformal_tuh(dataset=dataset, ratios=list(ratios), seed=seed)
+
+
+def _load_tfm_weights(model, args, run_idx: int) -> None:
+ """Load pre-trained tokenizer + fine-tuned classifier for run_idx (0-based).
+
+ Supports two layouts:
+ - Single classifier (PI TUAB setup): weights_dir/tfm_encoder_best_model_tuab.pth
+ Used for all seeds — only the data split varies across runs.
+ - Per-seed subdirs: weights_dir/{base}_{run_idx+1}/best_model.pth
+ """
+ single = os.path.join(args.weights_dir, "tfm_encoder_best_model_tuab.pth")
+ if os.path.isfile(single):
+ classifier_path = single
+ else:
+ base = os.path.basename(args.weights_dir)
+ classifier_path = os.path.join(args.weights_dir, f"{base}_{run_idx + 1}", "best_model.pth")
+ print(f" Loading TFM weights (run {run_idx + 1}): {classifier_path}")
+ model.load_pretrained_weights(
+ tokenizer_checkpoint_path=args.tokenizer_weights,
+ classifier_checkpoint_path=classifier_path,
+ )
+
+
+def set_seed(seed: int) -> None:
+ random.seed(seed)
+ np.random.seed(seed)
+ torch.manual_seed(seed)
+ if torch.cuda.is_available():
+ torch.cuda.manual_seed_all(seed)
+
+
+def _run_one_seed(
+ args,
+ sample_dataset,
+ test_ds,
+ test_loader,
+ device: str,
+ epochs: int,
+ run_seed: int,
+ alphas: list,
+ run_idx: int = 0,
+) -> dict:
+ """Train model + calibrate ClusterLabel for one seed across all alphas.
+
+ Training, embedding extraction, and base inference are done once; calibration
+ loops over alphas (fast — only threshold recomputed per alpha).
+
+ Returns {alpha: metrics_dict} where metrics_dict has keys:
+ accuracy, roc_auc_weighted_ovr, f1_weighted, coverage, miscoverage, avg_set_size
+ """
+ set_seed(run_seed)
+
+ train_ds, val_ds, cal_ds, _ = _do_split(
+ sample_dataset, args.ratios, run_seed, args.split_type
+ )
+ print(f" Split — Train: {len(train_ds)}, Val: {len(val_ds)}, "
+ f"Cal: {len(cal_ds)}, Test: {len(test_ds)} (fixed)")
+
+ train_loader = get_dataloader(train_ds, batch_size=args.batch_size, shuffle=True)
+ val_loader = (
+ get_dataloader(val_ds, batch_size=args.batch_size, shuffle=False)
+ if len(val_ds) else None
+ )
+
+ if args.model == "tfm":
+ model = TFMTokenizer(dataset=sample_dataset).to(device)
+ _load_tfm_weights(model, args, run_idx)
+ else:
+ model = ContraWR(dataset=sample_dataset, n_fft=args.n_fft).to(device)
+ print(" Training ContraWR...")
+ trainer_tmp = Trainer(model=model, device=device, enable_logging=False)
+ trainer_tmp.train(
+ train_dataloader=train_loader,
+ val_dataloader=val_loader,
+ epochs=epochs,
+ monitor="accuracy" if val_loader is not None else None,
+ )
+ trainer = Trainer(model=model, device=device, enable_logging=False)
+
+ # Base model metrics — computed once, shared across all alphas
+ y_true_base, y_prob_base, _ = trainer.inference(test_loader)
+ base_metrics = get_metrics_fn("multiclass")(
+ y_true_base, y_prob_base, metrics=["accuracy", "roc_auc_weighted_ovr", "f1_weighted"]
+ )
+
+ # Extract embeddings once — reused for every alpha
+ print(" Extracting embeddings for train and calibration splits...")
+ train_embeddings = extract_embeddings(model, train_ds, batch_size=args.batch_size, device=device)
+ cal_embeddings = extract_embeddings(model, cal_ds, batch_size=args.batch_size, device=device)
+
+ # Calibration + evaluation — fast; loop over every alpha
+ results = {}
+ for alpha in alphas:
+ print(f" Calibrating ClusterLabel predictor (alpha={alpha})...")
+ cluster_predictor = ClusterLabel(
+ model=model,
+ alpha=float(alpha),
+ n_clusters=args.n_clusters,
+ random_state=run_seed,
+ )
+ cluster_predictor.calibrate(
+ cal_dataset=cal_ds,
+ train_embeddings=train_embeddings,
+ cal_embeddings=cal_embeddings,
+ )
+
+ y_true, y_prob, _, extra = Trainer(model=cluster_predictor).inference(
+ test_loader, additional_outputs=["y_predset"]
+ )
+ conf_metrics = get_metrics_fn("multiclass")(
+ y_true, y_prob,
+ metrics=["accuracy", "miscoverage_ps"],
+ y_predset=extra["y_predset"],
+ )
+
+ predset = extra["y_predset"]
+ predset_t = torch.tensor(predset) if isinstance(predset, np.ndarray) else predset
+ avg_set_size = predset_t.float().sum(dim=1).mean().item()
+
+ miscoverage = conf_metrics["miscoverage_ps"]
+ if isinstance(miscoverage, np.ndarray):
+ miscoverage = float(miscoverage.item() if miscoverage.size == 1 else miscoverage.mean())
+ else:
+ miscoverage = float(miscoverage)
+
+ results[alpha] = {
+ "accuracy": float(base_metrics["accuracy"]),
+ "roc_auc_weighted_ovr": float(base_metrics["roc_auc_weighted_ovr"]),
+ "f1_weighted": float(base_metrics["f1_weighted"]),
+ "coverage": 1.0 - miscoverage,
+ "miscoverage": miscoverage,
+ "avg_set_size": avg_set_size,
+ }
+ return results
+
+
+def _print_single_run_results(metrics: dict, alpha: float, n_clusters: int) -> None:
+ print("\nClusterLabel Results:")
+ print(f" Accuracy: {metrics['accuracy']:.4f}")
+ print(f" ROC-AUC: {metrics['roc_auc_weighted_ovr']:.4f}")
+ print(f" F1: {metrics['f1_weighted']:.4f}")
+ print(f" Empirical coverage: {metrics['coverage']:.4f}")
+ print(f" Empirical miscoverage: {metrics['miscoverage']:.4f}")
+ print(f" Average set size: {metrics['avg_set_size']:.2f}")
+ print(f" Target coverage: {1 - alpha:.0%} (alpha={alpha})")
+ print(f" Number of clusters: {n_clusters}")
+
+
+def _print_multi_seed_summary(
+ all_metrics: list, run_seeds: list, alpha: float, n_test: int, n_clusters: int
+) -> None:
+ accs = np.array([m["accuracy"] for m in all_metrics])
+ roc_aucs = np.array([m["roc_auc_weighted_ovr"] for m in all_metrics])
+ f1s = np.array([m["f1_weighted"] for m in all_metrics])
+ coverages = np.array([m["coverage"] for m in all_metrics])
+ miscovs = np.array([m["miscoverage"] for m in all_metrics])
+ set_sizes = np.array([m["avg_set_size"] for m in all_metrics])
+ n_runs = len(all_metrics)
+
+ print("\n" + "=" * 80)
+ print(f"Per-run results — alpha={alpha} (ClusterLabel, fixed test set = TUH eval partition)")
+ print("=" * 80)
+ print(f" {'Run':<4} {'Seed':<6} {'Accuracy':<10} {'ROC-AUC':<10} {'F1':<8} "
+ f"{'Coverage':<10} {'Miscoverage':<12} {'Avg set size':<12}")
+ print(" " + "-" * 76)
+ for i in range(n_runs):
+ m = all_metrics[i]
+ print(f" {i+1:<4} {run_seeds[i]:<6} {m['accuracy']:<10.4f} "
+ f"{m['roc_auc_weighted_ovr']:<10.4f} {m['f1_weighted']:<8.4f} {m['coverage']:<10.4f} "
+ f"{m['miscoverage']:<12.4f} {m['avg_set_size']:<12.2f}")
+
+ print("\n" + "=" * 80)
+ print(f"Summary — alpha={alpha} (mean \u00b1 std over {n_runs} runs, fixed test set)")
+ print(" Method: ClusterLabel")
+ print("=" * 80)
+ print(f" Accuracy: {accs.mean():.4f} \u00b1 {accs.std():.4f}")
+ print(f" ROC-AUC: {roc_aucs.mean():.4f} \u00b1 {roc_aucs.std():.4f}")
+ print(f" F1: {f1s.mean():.4f} \u00b1 {f1s.std():.4f}")
+ print(f" Empirical coverage: {coverages.mean():.4f} \u00b1 {coverages.std():.4f}")
+ print(f" Empirical miscoverage: {miscovs.mean():.4f} \u00b1 {miscovs.std():.4f}")
+ print(f" Average set size: {set_sizes.mean():.2f} \u00b1 {set_sizes.std():.2f}")
+ print(f" Target coverage: {1 - alpha:.0%} (alpha={alpha})")
+ print(f" Number of clusters: {n_clusters}")
+ print(f" Test set size: {n_test} (fixed across runs)")
+ print(f" Run seeds: {run_seeds}")
+ print("\n--- Min / Max (across runs) ---")
+ print(f" Coverage: [{coverages.min():.4f}, {coverages.max():.4f}]")
+ print(f" Set size: [{set_sizes.min():.2f}, {set_sizes.max():.2f}]")
+ print(f" Accuracy: [{accs.min():.4f}, {accs.max():.4f}]")
+ print(f" ROC-AUC: [{roc_aucs.min():.4f}, {roc_aucs.max():.4f}]")
+
+
+def _main(args: argparse.Namespace) -> None:
+ device = args.device or ("cuda:0" if torch.cuda.is_available() else "cpu")
+ root = Path(args.root)
+ if not root.exists():
+ raise FileNotFoundError(
+ f"TUAB root not found: {root}. "
+ "Pass --root to point to your downloaded TUAB edf/ directory."
+ )
+
+ epochs = 2 if args.quick_test else args.epochs
+ quick_test_max_samples = 2000
+ if args.quick_test:
+ print("*** QUICK TEST MODE (dev=True, 2 epochs, max 2000 samples) ***")
+
+ # -------------------------------------------------------------------------
+ # STEP 1: Load dataset ONCE — shared across all seeds
+ # -------------------------------------------------------------------------
+ print("=" * 80)
+ print("STEP 1: Load TUAB + build task dataset (shared across all seeds)")
+ print("=" * 80)
+ dataset = TUABDataset(root=str(root), subset=args.subset, dev=args.quick_test)
+ sample_dataset = dataset.set_task(EEGAbnormalTUAB(normalization="95th_percentile"), num_workers=16)
+ if args.quick_test and len(sample_dataset) > quick_test_max_samples:
+ sample_dataset = sample_dataset.subset(range(quick_test_max_samples))
+ print(f"Capped to {quick_test_max_samples} samples for quick-test.")
+ print(f"Task samples: {len(sample_dataset)}")
+ print(f"Input schema: {sample_dataset.input_schema}")
+ print(f"Output schema: {sample_dataset.output_schema}")
+ if len(sample_dataset) == 0:
+ raise RuntimeError("No samples produced. Verify TUAB root/subset/task.")
+
+ # -------------------------------------------------------------------------
+ # STEP 2: Extract the fixed test set ONCE
+ # -------------------------------------------------------------------------
+ print("\n" + "=" * 80)
+ print("STEP 2: Extract fixed test set (TUH eval partition — same for all seeds)")
+ print("=" * 80)
+ _, _, _, test_ds = _do_split(
+ sample_dataset, args.ratios, args.seed, args.split_type
+ )
+ if len(test_ds) == 0 and args.quick_test:
+ print(" [quick-test] TUH eval partition empty in dev mode — using random 20% as test set.")
+ _, _, _, test_ds = split_by_sample_conformal(
+ dataset=sample_dataset, ratios=[0.6, 0.1, 0.1, 0.2], seed=args.seed
+ )
+ test_loader = get_dataloader(test_ds, batch_size=args.batch_size, shuffle=False)
+ print(f"Test: {len(test_ds)} (fixed)")
+
+ # -------------------------------------------------------------------------
+ # Determine run seeds
+ # -------------------------------------------------------------------------
+ if args.seeds is not None:
+ run_seeds = [int(s.strip()) for s in args.seeds.split(",")]
+ else:
+ run_seeds = [args.seed + i for i in range(args.n_seeds)]
+
+ alphas = [float(a.strip()) for a in args.alphas.split(",")] if args.alphas else [args.alpha]
+
+ use_multi_seed = len(run_seeds) > 1
+ print(f"\nRun config: {'multi-seed (' + str(len(run_seeds)) + ' runs)' if use_multi_seed else 'single run'}")
+ print(f"Seeds: {run_seeds}, alphas={alphas}, n_clusters={args.n_clusters}")
+
+ # -------------------------------------------------------------------------
+ # STEP 3+: Train once per seed; calibrate for every alpha (fast)
+ # -------------------------------------------------------------------------
+ all_metrics = {alpha: [] for alpha in alphas}
+ for run_i, run_seed in enumerate(run_seeds):
+ print("\n" + "=" * 80)
+ if use_multi_seed:
+ print(f"Run {run_i + 1} / {len(run_seeds)} (seed={run_seed})")
+ else:
+ print(f"STEP 3–4: Train + Conformal Calibration (seed={run_seed})")
+ print("=" * 80)
+
+ seed_results = _run_one_seed(
+ args, sample_dataset, test_ds, test_loader, device, epochs, run_seed, alphas,
+ run_idx=run_i,
+ )
+ for alpha in alphas:
+ all_metrics[alpha].append(seed_results[alpha])
+
+ if use_multi_seed:
+ m = seed_results[alphas[0]]
+ print(f" [Run {run_i + 1} result (alpha={alphas[0]})] "
+ f"acc={m['accuracy']:.4f}, roc_auc={m['roc_auc_weighted_ovr']:.4f}, "
+ f"cov={m['coverage']:.4f}, set_size={m['avg_set_size']:.2f}")
+
+ for alpha in alphas:
+ if not use_multi_seed:
+ _print_single_run_results(all_metrics[alpha][0], alpha, args.n_clusters)
+ else:
+ _print_multi_seed_summary(all_metrics[alpha], run_seeds, alpha, len(test_ds), args.n_clusters)
+
+
+def main() -> None:
+ args = parse_args()
+
+ orig_stdout, orig_stderr = sys.stdout, sys.stderr
+ log_file = None
+ if args.log_file:
+ log_file = open(args.log_file, "w", encoding="utf-8")
+ sys.stdout = _Tee(orig_stdout, log_file)
+ sys.stderr = _Tee(orig_stderr, log_file)
+
+ try:
+ _main(args)
+ finally:
+ if log_file is not None:
+ sys.stdout = orig_stdout
+ sys.stderr = orig_stderr
+ log_file.close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/conformal_eeg/tuab_ncp_conformal.py b/examples/conformal_eeg/tuab_ncp_conformal.py
new file mode 100644
index 000000000..90166686a
--- /dev/null
+++ b/examples/conformal_eeg/tuab_ncp_conformal.py
@@ -0,0 +1,471 @@
+"""Neighborhood Conformal Prediction (NCP) on TUAB Abnormal EEG Detection using ContraWR.
+
+This script:
+1) Loads the TUAB dataset and applies the EEGAbnormalTUAB task.
+2) Splits into train/val/cal/test using the TUH-aware split conformal protocol.
+3) Trains a ContraWR model.
+4) Extracts calibration embeddings and calibrates a NeighborhoodLabel (NCP) predictor.
+5) Evaluates prediction-set coverage/miscoverage and efficiency on the test split.
+
+With --n-seeds > 1: fixes the test set (TUH eval partition), runs multiple training runs
+with different seeds (different train/val/cal splits and model init), reports
+coverage / set size / accuracy as mean ± std (error bars).
+
+Example (from repo root):
+ python examples/conformal_eeg/tuab_ncp_conformal.py --root /srv/local/data/TUH/tuh_eeg_abnormal/v3.0.0/edf
+ python examples/conformal_eeg/tuab_ncp_conformal.py --quick-test --log-file quicktest_ncp.log
+ python examples/conformal_eeg/tuab_ncp_conformal.py --alpha 0.1 --n-seeds 5 --split-seed 0 --log-file ncp_seeds5.log
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import random
+import sys
+from pathlib import Path
+
+import numpy as np
+import torch
+
+
+class _Tee:
+ """Writes to both a stream and a file."""
+
+ def __init__(self, stream, file):
+ self._stream = stream
+ self._file = file
+
+ def write(self, data):
+ self._stream.write(data)
+ self._file.write(data)
+ self._file.flush()
+
+ def flush(self):
+ self._stream.flush()
+ self._file.flush()
+
+
+from pyhealth.calib.predictionset.cluster import NeighborhoodLabel
+from pyhealth.calib.utils import extract_embeddings
+from pyhealth.datasets import TUABDataset, get_dataloader, split_by_patient_conformal_tuh, split_by_sample_conformal_tuh, split_by_sample_conformal
+from pyhealth.models import ContraWR, TFMTokenizer
+from pyhealth.tasks import EEGAbnormalTUAB
+from pyhealth.trainer import Trainer, get_metrics_fn
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="Neighborhood conformal prediction (NCP) on TUAB abnormal EEG detection using ContraWR."
+ )
+ parser.add_argument(
+ "--root",
+ type=str,
+ default="/srv/local/data/TUH/tuh_eeg_abnormal/v3.0.0/edf",
+ help="Path to TUAB edf/ folder.",
+ )
+ parser.add_argument("--subset", type=str, default="both", choices=["train", "eval", "both"])
+ parser.add_argument("--seed", type=int, default=42, help="Run seed (or first of run seeds when n-seeds > 1).")
+ parser.add_argument(
+ "--n-seeds",
+ type=int,
+ default=1,
+ help="Number of runs for mean±std. Test set fixed (TUH eval partition); train/val/cal vary by seed.",
+ )
+ parser.add_argument(
+ "--split-seed",
+ type=int,
+ default=0,
+ help="Seed used to obtain the fixed test set in multi-seed mode. Since test = TUH eval partition, this only affects the initial train-pool shuffle (not which samples are in test).",
+ )
+ parser.add_argument(
+ "--seeds",
+ type=str,
+ default=None,
+ help="Comma-separated run seeds, e.g. 42,43,44,45,46. Overrides --seed and --n-seeds.",
+ )
+ parser.add_argument("--batch-size", type=int, default=64)
+ parser.add_argument("--epochs", type=int, default=20)
+ parser.add_argument("--alpha", type=float, default=0.1, help="Miscoverage rate (e.g., 0.1 => 90% target coverage).")
+ parser.add_argument(
+ "--alphas", type=str, default=None,
+ help="Comma-separated miscoverage rates, e.g. '0.2,0.1,0.05,0.01'. Overrides --alpha.",
+ )
+ parser.add_argument(
+ "--ratios",
+ type=float,
+ nargs=3,
+ default=(0.6, 0.2, 0.2),
+ metavar=("TRAIN", "VAL", "CAL"),
+ help="Ratios for splitting the TUH train partition into train/val/cal. Must sum to 1.0. Test is fixed as the TUH eval partition.",
+ )
+ parser.add_argument(
+ "--k-neighbors",
+ type=int,
+ default=50,
+ help="Number of nearest calibration neighbors for NCP.",
+ )
+ parser.add_argument(
+ "--lambda-L",
+ type=float,
+ default=100.0,
+ help="Temperature for NCP exponential weights; smaller => more localization.",
+ )
+ parser.add_argument("--n-fft", type=int, default=128, help="STFT FFT size used by ContraWR.")
+ parser.add_argument(
+ "--model", type=str, default="contrawr", choices=["contrawr", "tfm"],
+ help="Backbone model: 'contrawr' (default) or 'tfm' (TFMTokenizer).",
+ )
+ parser.add_argument(
+ "--device",
+ type=str,
+ default=None,
+ help="Device string, e.g. 'cuda:0' or 'cpu'. Defaults to auto-detect.",
+ )
+ parser.add_argument(
+ "--log-file",
+ type=str,
+ default=None,
+ help="Path to log file. Stdout and stderr are teed to this file.",
+ )
+ parser.add_argument(
+ "--quick-test",
+ action="store_true",
+ help="Smoke test: dev=True, max 2000 samples, 2 epochs, ~5-10 min.",
+ )
+ parser.add_argument(
+ "--weights-dir",
+ type=str,
+ default="/shared/eng/conformal_eeg",
+ help="Root folder of TFM classifier checkpoints (only with --model tfm). "
+ "If the directory contains tfm_encoder_best_model_tuab.pth directly, "
+ "that single checkpoint is used for all seeds (PI TUAB setup). "
+ "Otherwise expects per-seed subdirs {base}_1..N/best_model.pth.",
+ )
+ parser.add_argument(
+ "--tokenizer-weights",
+ type=str,
+ default="/shared/eng/conformal_eeg/tfm_tokenizer_tuab.pth",
+ help="Path to the pre-trained TFM tokenizer weights (only with --model tfm).",
+ )
+ parser.add_argument(
+ "--split-type",
+ type=str,
+ default="patient",
+ choices=["patient", "sample"],
+ help="Split strategy: 'patient' (default, patient-level, no leakage) or "
+ "'sample' (original sample-level, for comparison).",
+ )
+ return parser.parse_args()
+
+
+def _do_split(dataset, ratios, seed, split_type):
+ """Dispatch to the correct TUH split function based on split_type."""
+ if split_type == "patient":
+ return split_by_patient_conformal_tuh(dataset=dataset, ratios=list(ratios), seed=seed)
+ else:
+ return split_by_sample_conformal_tuh(dataset=dataset, ratios=list(ratios), seed=seed)
+
+
+def _load_tfm_weights(model, args, run_idx: int) -> None:
+ """Load pre-trained tokenizer + fine-tuned classifier for run_idx (0-based).
+
+ Supports two layouts:
+ - Single classifier (PI TUAB setup): weights_dir/tfm_encoder_best_model_tuab.pth
+ Used for all seeds — only the data split varies across runs.
+ - Per-seed subdirs: weights_dir/{base}_{run_idx+1}/best_model.pth
+ """
+ single = os.path.join(args.weights_dir, "tfm_encoder_best_model_tuab.pth")
+ if os.path.isfile(single):
+ classifier_path = single
+ else:
+ base = os.path.basename(args.weights_dir)
+ classifier_path = os.path.join(args.weights_dir, f"{base}_{run_idx + 1}", "best_model.pth")
+ print(f" Loading TFM weights (run {run_idx + 1}): {classifier_path}")
+ model.load_pretrained_weights(
+ tokenizer_checkpoint_path=args.tokenizer_weights,
+ classifier_checkpoint_path=classifier_path,
+ )
+
+
+def set_seed(seed: int) -> None:
+ random.seed(seed)
+ np.random.seed(seed)
+ torch.manual_seed(seed)
+ if torch.cuda.is_available():
+ torch.cuda.manual_seed_all(seed)
+
+
+def _split_train_pool_for_run(sample_dataset, ratios, run_seed, split_type="patient"):
+ """Re-split the TUH train partition into train/val/cal for one run seed.
+
+ The test set (TUH eval partition) is always fixed regardless of seed, so
+ only train/val/cal change across runs in multi-seed mode.
+ """
+ train_ds, val_ds, cal_ds, _ = _do_split(sample_dataset, ratios, run_seed, split_type)
+ return train_ds, val_ds, cal_ds
+
+
+def _run_one_ncp(
+ sample_dataset,
+ train_ds,
+ val_ds,
+ cal_ds,
+ test_loader,
+ args,
+ device,
+ epochs,
+ alphas: list,
+ run_idx: int = 0,
+):
+ """Train model + calibrate NCP for one seed across all alphas.
+
+ Training, embedding extraction, and base inference are done once; calibration
+ loops over alphas (fast — only threshold recomputed per alpha).
+
+ Returns {alpha: metrics_dict} where metrics_dict has keys:
+ accuracy, roc_auc_weighted_ovr, f1_weighted, coverage, miscoverage, avg_set_size
+ """
+ train_loader = get_dataloader(train_ds, batch_size=args.batch_size, shuffle=True)
+ val_loader = get_dataloader(val_ds, batch_size=args.batch_size, shuffle=False) if len(val_ds) else None
+
+ if args.model == "tfm":
+ model = TFMTokenizer(dataset=sample_dataset).to(device)
+ _load_tfm_weights(model, args, run_idx)
+ else:
+ model = ContraWR(dataset=sample_dataset, n_fft=args.n_fft).to(device)
+ print(" Training ContraWR...")
+ trainer_tmp = Trainer(model=model, device=device, enable_logging=False)
+ trainer_tmp.train(
+ train_dataloader=train_loader,
+ val_dataloader=val_loader,
+ epochs=epochs,
+ monitor="accuracy" if val_loader is not None else None,
+ )
+ trainer = Trainer(model=model, device=device, enable_logging=False)
+
+ # Base model metrics — computed once, shared across all alphas
+ y_true_base, y_prob_base, _ = trainer.inference(test_loader)
+ base_metrics = get_metrics_fn("multiclass")(
+ y_true_base, y_prob_base, metrics=["accuracy", "roc_auc_weighted_ovr", "f1_weighted"]
+ )
+
+ # Extract calibration embeddings once — reused for every alpha
+ print(" Extracting calibration embeddings...")
+ cal_embeddings = extract_embeddings(model, cal_ds, batch_size=args.batch_size, device=device)
+
+ # Calibration + evaluation — fast; loop over every alpha
+ results = {}
+ for alpha in alphas:
+ print(f" Calibrating NCP predictor (alpha={alpha})...")
+ ncp_predictor = NeighborhoodLabel(
+ model=model,
+ alpha=float(alpha),
+ k_neighbors=args.k_neighbors,
+ lambda_L=args.lambda_L,
+ )
+ ncp_predictor.calibrate(cal_dataset=cal_ds, cal_embeddings=cal_embeddings)
+
+ y_true, y_prob, _, extra = Trainer(model=ncp_predictor).inference(
+ test_loader, additional_outputs=["y_predset"]
+ )
+ ncp_metrics = get_metrics_fn("multiclass")(
+ y_true, y_prob, metrics=["accuracy", "miscoverage_ps"], y_predset=extra["y_predset"]
+ )
+ predset = extra["y_predset"]
+ predset_t = torch.tensor(predset) if isinstance(predset, np.ndarray) else predset
+ avg_set_size = predset_t.float().sum(dim=1).mean().item()
+
+ miscoverage = ncp_metrics["miscoverage_ps"]
+ if isinstance(miscoverage, np.ndarray):
+ miscoverage = float(miscoverage.item() if miscoverage.size == 1 else miscoverage.mean())
+ else:
+ miscoverage = float(miscoverage)
+
+ results[alpha] = {
+ "accuracy": float(base_metrics["accuracy"]),
+ "roc_auc_weighted_ovr": float(base_metrics["roc_auc_weighted_ovr"]),
+ "f1_weighted": float(base_metrics["f1_weighted"]),
+ "coverage": 1.0 - miscoverage,
+ "miscoverage": miscoverage,
+ "avg_set_size": avg_set_size,
+ }
+ return results
+
+
+def main() -> None:
+ args = parse_args()
+ if args.n_seeds <= 1 and args.seeds is None:
+ set_seed(args.seed)
+
+ orig_stdout, orig_stderr = sys.stdout, sys.stderr
+ log_file = None
+ if args.log_file:
+ log_file = open(args.log_file, "w", encoding="utf-8")
+ sys.stdout = _Tee(orig_stdout, log_file)
+ sys.stderr = _Tee(orig_stderr, log_file)
+
+ try:
+ _run(args)
+ finally:
+ if log_file is not None:
+ sys.stdout = orig_stdout
+ sys.stderr = orig_stderr
+ log_file.close()
+
+
+def _run(args: argparse.Namespace) -> None:
+ device = args.device or ("cuda:0" if torch.cuda.is_available() else "cpu")
+ root = Path(args.root)
+ if not root.exists():
+ raise FileNotFoundError(
+ f"TUAB root not found: {root}. "
+ "Pass --root to point to your downloaded TUAB edf/ directory."
+ )
+
+ epochs = 2 if args.quick_test else args.epochs
+ quick_test_max_samples = 2000
+ if args.quick_test:
+ print("*** QUICK TEST MODE (dev=True, 2 epochs, max 2000 samples) ***")
+
+ print("=" * 80)
+ print("STEP 1: Load TUAB + build task dataset")
+ print("=" * 80)
+ dataset = TUABDataset(root=str(root), subset=args.subset, dev=args.quick_test)
+ sample_dataset = dataset.set_task(EEGAbnormalTUAB(normalization="95th_percentile"), num_workers=16)
+ if args.quick_test and len(sample_dataset) > quick_test_max_samples:
+ sample_dataset = sample_dataset.subset(range(quick_test_max_samples))
+ print(f"Capped to {quick_test_max_samples} samples for quick-test.")
+
+ print(f"Task samples: {len(sample_dataset)}")
+ print(f"Input schema: {sample_dataset.input_schema}")
+ print(f"Output schema: {sample_dataset.output_schema}")
+
+ if len(sample_dataset) == 0:
+ raise RuntimeError("No samples produced. Verify TUAB root/subset/task.")
+
+ # Parse alphas and run seeds
+ alphas = [float(a.strip()) for a in args.alphas.split(",")] if args.alphas else [args.alpha]
+ ratios = list(args.ratios)
+ use_multi_seed = args.n_seeds > 1 or args.seeds is not None
+ run_seeds = (
+ [int(s.strip()) for s in args.seeds.split(",")]
+ if args.seeds
+ else [args.seed + i for i in range(args.n_seeds)]
+ )
+ n_runs = len(run_seeds)
+
+ # -------------------------------------------------------------------------
+ # STEP 2: Extract the fixed test set ONCE
+ # -------------------------------------------------------------------------
+ print("\n" + "=" * 80)
+ print("STEP 2: Extract fixed test set (TUH eval partition — same for all seeds)")
+ print("=" * 80)
+ _, _, _, test_ds = _do_split(
+ sample_dataset, ratios, args.split_seed, args.split_type
+ )
+ if len(test_ds) == 0 and args.quick_test:
+ print(" [quick-test] TUH eval partition empty in dev mode — using random 20% as test set.")
+ _, _, _, test_ds = split_by_sample_conformal(
+ dataset=sample_dataset, ratios=[0.6, 0.1, 0.1, 0.2], seed=args.split_seed
+ )
+ test_loader = get_dataloader(test_ds, batch_size=args.batch_size, shuffle=False)
+ n_test = len(test_ds)
+ print(f"Test: {n_test} (fixed)")
+
+ print(f"\nRun config: {'multi-seed (' + str(n_runs) + ' runs)' if use_multi_seed else 'single run'}")
+ print(f"Seeds: {run_seeds}, alphas={alphas}, k_neighbors={args.k_neighbors}")
+
+ # -------------------------------------------------------------------------
+ # STEP 3+: Train once per seed; calibrate for every alpha (fast)
+ # -------------------------------------------------------------------------
+ all_metrics = {alpha: [] for alpha in alphas}
+ for run_i, run_seed in enumerate(run_seeds):
+ print("\n" + "=" * 80)
+ if use_multi_seed:
+ print(f"Run {run_i + 1} / {n_runs} (seed={run_seed})")
+ else:
+ print(f"STEP 3–4: Train + NCP Calibration (seed={run_seed})")
+ print("=" * 80)
+ set_seed(run_seed)
+ train_ds, val_ds, cal_ds = _split_train_pool_for_run(sample_dataset, ratios, run_seed, args.split_type)
+ print(f" Split — Train: {len(train_ds)}, Val: {len(val_ds)}, "
+ f"Cal: {len(cal_ds)}, Test: {n_test} (fixed)")
+
+ seed_results = _run_one_ncp(
+ sample_dataset=sample_dataset,
+ train_ds=train_ds,
+ val_ds=val_ds,
+ cal_ds=cal_ds,
+ test_loader=test_loader,
+ args=args,
+ device=device,
+ epochs=epochs,
+ alphas=alphas,
+ run_idx=run_i,
+ )
+ for alpha in alphas:
+ all_metrics[alpha].append(seed_results[alpha])
+
+ if use_multi_seed:
+ m = seed_results[alphas[0]]
+ print(f" [Run {run_i + 1} result (alpha={alphas[0]})] "
+ f"acc={m['accuracy']:.4f}, roc_auc={m['roc_auc_weighted_ovr']:.4f}, "
+ f"cov={m['coverage']:.4f}, set_size={m['avg_set_size']:.2f}")
+
+ for alpha in alphas:
+ mlist = all_metrics[alpha]
+ accs = np.array([m["accuracy"] for m in mlist])
+ roc_aucs = np.array([m["roc_auc_weighted_ovr"] for m in mlist])
+ f1s = np.array([m["f1_weighted"] for m in mlist])
+ coverages = np.array([m["coverage"] for m in mlist])
+ miscovs = np.array([m["miscoverage"] for m in mlist])
+ set_sizes = np.array([m["avg_set_size"] for m in mlist])
+
+ if not use_multi_seed:
+ print("\n" + "=" * 80)
+ print(f"Summary — alpha={alpha} (single run, fixed test set)")
+ print(" Method: NeighborhoodLabel")
+ print("=" * 80)
+ print(f" Accuracy: {accs[0]:.4f}")
+ print(f" ROC-AUC: {roc_aucs[0]:.4f}")
+ print(f" F1: {f1s[0]:.4f}")
+ print(f" Empirical coverage: {coverages[0]:.4f}")
+ print(f" Empirical miscoverage: {miscovs[0]:.4f}")
+ print(f" Average set size: {set_sizes[0]:.2f}")
+ print(f" Target coverage: {1 - alpha:.0%} (alpha={alpha})")
+ print(f" k_neighbors: {args.k_neighbors}, lambda_L: {args.lambda_L}")
+ else:
+ print("\n" + "=" * 80)
+ print(f"Per-run results — alpha={alpha} (NeighborhoodLabel, fixed test set = TUH eval partition)")
+ print("=" * 80)
+ print(f" {'Run':<4} {'Seed':<6} {'Accuracy':<10} {'ROC-AUC':<10} {'F1':<8} "
+ f"{'Coverage':<10} {'Miscoverage':<12} {'Avg set size':<12}")
+ print(" " + "-" * 76)
+ for i in range(n_runs):
+ print(f" {i+1:<4} {run_seeds[i]:<6} {accs[i]:<10.4f} {roc_aucs[i]:<10.4f} "
+ f"{f1s[i]:<8.4f} {coverages[i]:<10.4f} {miscovs[i]:<12.4f} {set_sizes[i]:<12.2f}")
+
+ print("\n" + "=" * 80)
+ print(f"Summary — alpha={alpha} (mean \u00b1 std over {n_runs} runs, fixed test set)")
+ print(" Method: NeighborhoodLabel")
+ print("=" * 80)
+ print(f" Accuracy: {accs.mean():.4f} \u00b1 {accs.std():.4f}")
+ print(f" ROC-AUC: {roc_aucs.mean():.4f} \u00b1 {roc_aucs.std():.4f}")
+ print(f" F1: {f1s.mean():.4f} \u00b1 {f1s.std():.4f}")
+ print(f" Empirical coverage: {coverages.mean():.4f} \u00b1 {coverages.std():.4f}")
+ print(f" Empirical miscoverage: {miscovs.mean():.4f} \u00b1 {miscovs.std():.4f}")
+ print(f" Average set size: {set_sizes.mean():.2f} \u00b1 {set_sizes.std():.2f}")
+ print(f" Target coverage: {1 - alpha:.0%} (alpha={alpha})")
+ print(f" k_neighbors: {args.k_neighbors}, lambda_L: {args.lambda_L}")
+ print(f" Test set size: {n_test} (fixed across runs)")
+ print(f" Run seeds: {run_seeds}")
+ print("\n--- Min / Max (across runs) ---")
+ print(f" Coverage: [{coverages.min():.4f}, {coverages.max():.4f}]")
+ print(f" Set size: [{set_sizes.min():.2f}, {set_sizes.max():.2f}]")
+ print(f" Accuracy: [{accs.min():.4f}, {accs.max():.4f}]")
+ print(f" ROC-AUC: [{roc_aucs.min():.4f}, {roc_aucs.max():.4f}]")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/conformal_eeg/tuev_conventional_conformal.py b/examples/conformal_eeg/tuev_conventional_conformal.py
index b62dc73d1..e5542c7d4 100644
--- a/examples/conformal_eeg/tuev_conventional_conformal.py
+++ b/examples/conformal_eeg/tuev_conventional_conformal.py
@@ -1,65 +1,170 @@
-"""Conventional Conformal Prediction (LABEL) on TUEV EEG Events using ContraWR.
+"""Conventional Conformal Prediction (LABEL) on TUEV EEG Events.
+
+Supports both ContraWR (default) and TFMTokenizer via --model contrawr|tfm.
This script:
-1) Loads the TUEV dataset and applies the EEGEventsTUEV task.
-2) Splits into train/val/cal/test using split conformal protocol.
-3) Trains a ContraWR model.
-4) Calibrates a LABEL prediction-set predictor on the calibration split.
-5) Evaluates prediction-set coverage/miscoverage and efficiency on the test split.
+1) Loads the TUEV dataset and applies the EEGEventsTUEV task (once, shared across all seeds).
+2) Extracts the fixed test set (TUH eval partition — never changes across seeds).
+3) For each seed: splits the TUH train partition into train/val/cal, trains the chosen model,
+ calibrates a LABEL predictor, and evaluates on the fixed test set.
+4) Reports per-run results and mean ± std summary across all seeds.
-Example (from repo root):
+Single-seed usage (from repo root):
python examples/conformal_eeg/tuev_conventional_conformal.py --root downloads/tuev/v2.0.1/edf
+ python examples/conformal_eeg/tuev_conventional_conformal.py --model tfm --root downloads/tuev/v2.0.1/edf
+
+Multi-seed usage (recommended for papers):
+ python examples/conformal_eeg/tuev_conventional_conformal.py \\
+ --root downloads/tuev/v2.0.1/edf --model tfm --n-seeds 5 --seed 42 --alpha 0.1 \\
+ --log-file tuev_conventional_tfm_alpha0.1_5seeds.log
"""
from __future__ import annotations
import argparse
+import os
import random
+import sys
from pathlib import Path
import numpy as np
import torch
from pyhealth.calib.predictionset import LABEL
-from pyhealth.datasets import TUEVDataset, get_dataloader, split_by_sample_conformal
-from pyhealth.models import ContraWR
+from pyhealth.datasets import TUEVDataset, get_dataloader, split_by_patient_conformal_tuh, split_by_sample_conformal_tuh, split_by_sample_conformal
+from pyhealth.models import ContraWR, TFMTokenizer
from pyhealth.tasks import EEGEventsTUEV
from pyhealth.trainer import Trainer, get_metrics_fn
+class _Tee:
+ """Writes to both a stream and a file simultaneously."""
+
+ def __init__(self, stream, file):
+ self._stream = stream
+ self._file = file
+
+ def write(self, data):
+ self._stream.write(data)
+ self._file.write(data)
+ self._file.flush()
+
+ def flush(self):
+ self._stream.flush()
+ self._file.flush()
+
+
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
- description="Conventional conformal prediction (LABEL) on TUEV EEG events using ContraWR."
+ description="Conventional conformal prediction (LABEL) on TUEV EEG events."
)
parser.add_argument(
"--root",
type=str,
- default="downloads/tuev/v2.0.1/edf",
+ default="/srv/local/data/TUH/tuh_eeg_events/v2.0.0/edf",
help="Path to TUEV edf/ folder.",
)
parser.add_argument("--subset", type=str, default="both", choices=["train", "eval", "both"])
- parser.add_argument("--seed", type=int, default=42)
+ parser.add_argument(
+ "--seed",
+ type=int,
+ default=42,
+ help="Base seed. With --n-seeds N, runs seeds seed, seed+1, ..., seed+N-1.",
+ )
parser.add_argument("--batch-size", type=int, default=32)
- parser.add_argument("--epochs", type=int, default=2)
- parser.add_argument("--alpha", type=float, default=0.1, help="Miscoverage rate (e.g., 0.1 => 90% target coverage).")
+ parser.add_argument("--epochs", type=int, default=20)
+ parser.add_argument(
+ "--alpha", type=float, default=0.1,
+ help="Miscoverage rate (e.g., 0.1 => 90% target coverage).",
+ )
+ parser.add_argument(
+ "--alphas", type=str, default=None,
+ help="Comma-separated miscoverage rates, e.g. '0.2,0.1,0.05,0.01'. Overrides --alpha.",
+ )
parser.add_argument(
"--ratios",
type=float,
- nargs=4,
- default=(0.6, 0.1, 0.15, 0.15),
- metavar=("TRAIN", "VAL", "CAL", "TEST"),
- help="Split ratios for train/val/cal/test. Must sum to 1.0.",
+ nargs=3,
+ default=(0.6, 0.2, 0.2),
+ metavar=("TRAIN", "VAL", "CAL"),
+ help="Ratios for splitting the TUH train partition into train/val/cal. "
+ "Must sum to 1.0. Test is fixed as the TUH eval partition.",
)
parser.add_argument("--n-fft", type=int, default=128, help="STFT FFT size used by ContraWR.")
parser.add_argument(
- "--device",
+ "--model", type=str, default="contrawr", choices=["contrawr", "tfm"],
+ help="Backbone model: 'contrawr' (default) or 'tfm' (TFMTokenizer).",
+ )
+ parser.add_argument(
+ "--device", type=str, default=None,
+ help="Device string, e.g. 'cuda:0' or 'cpu'. Defaults to auto-detect.",
+ )
+ parser.add_argument(
+ "--n-seeds",
+ type=int,
+ default=1,
+ help="Number of seeds to run sequentially for mean±std reporting. "
+ "Seeds are seed, seed+1, ..., seed+n_seeds-1.",
+ )
+ parser.add_argument(
+ "--seeds",
type=str,
default=None,
- help="Device string, e.g. 'cuda:0' or 'cpu'. Defaults to auto-detect.",
+ help="Explicit comma-separated seeds, e.g. '42,43,44,45,46'. "
+ "Overrides --seed and --n-seeds.",
+ )
+ parser.add_argument(
+ "--weights-dir",
+ type=str,
+ default="weightfiles/TFM_Tokenizer_multiple_finetuned_on_TUEV",
+ help="Root folder of fine-tuned TFM classifier checkpoints (used only with --model tfm). "
+ "Expected sub-folders: /_1/best_model.pth, ..., _5/best_model.pth.",
+ )
+ parser.add_argument(
+ "--tokenizer-weights",
+ type=str,
+ default="weightfiles/tfm_tokenizer_last.pth",
+ help="Path to the pre-trained TFM tokenizer weights (used only with --model tfm).",
+ )
+ parser.add_argument(
+ "--log-file", type=str, default=None,
+ help="Path to log file. Stdout and stderr are teed to this file.",
+ )
+ parser.add_argument(
+ "--quick-test",
+ action="store_true",
+ help="Smoke test: dev=True, max 2000 samples, 2 epochs.",
+ )
+ parser.add_argument(
+ "--split-type",
+ type=str,
+ default="patient",
+ choices=["patient", "sample"],
+ help="Split strategy: 'patient' (default, patient-level, no leakage) or "
+ "'sample' (original sample-level, for comparison).",
)
return parser.parse_args()
+def _do_split(dataset, ratios, seed, split_type):
+ """Dispatch to the correct TUH split function based on split_type."""
+ if split_type == "patient":
+ return split_by_patient_conformal_tuh(dataset=dataset, ratios=list(ratios), seed=seed)
+ else:
+ return split_by_sample_conformal_tuh(dataset=dataset, ratios=list(ratios), seed=seed)
+
+
+def _load_tfm_weights(model, args, run_idx: int) -> None:
+ """Load pre-trained tokenizer + fine-tuned classifier for run_idx (0-based)."""
+ base = os.path.basename(args.weights_dir)
+ classifier_path = os.path.join(args.weights_dir, f"{base}_{run_idx + 1}", "best_model.pth")
+ print(f" Loading TFM weights (run {run_idx + 1}): {classifier_path}")
+ model.load_pretrained_weights(
+ tokenizer_checkpoint_path=args.tokenizer_weights,
+ classifier_checkpoint_path=classifier_path,
+ )
+
+
def set_seed(seed: int) -> None:
random.seed(seed)
np.random.seed(seed)
@@ -68,10 +173,146 @@ def set_seed(seed: int) -> None:
torch.cuda.manual_seed_all(seed)
-def main() -> None:
- args = parse_args()
- set_seed(args.seed)
+def _run_one_seed(
+ args,
+ sample_dataset,
+ test_ds,
+ test_loader,
+ device: str,
+ epochs: int,
+ run_seed: int,
+ alphas: list,
+ run_idx: int = 0,
+) -> dict:
+ """Train model + calibrate LABEL for one seed across all alphas.
+
+ Training and base-model inference are done once; calibration loops over alphas (fast).
+
+ Returns {alpha: metrics_dict} where metrics_dict has keys:
+ accuracy, f1_weighted, coverage, miscoverage, avg_set_size
+ """
+ set_seed(run_seed)
+
+ train_ds, val_ds, cal_ds, _ = _do_split(
+ sample_dataset, args.ratios, run_seed, args.split_type
+ )
+ print(f" Split — Train: {len(train_ds)}, Val: {len(val_ds)}, "
+ f"Cal: {len(cal_ds)}, Test: {len(test_ds)} (fixed)")
+
+ train_loader = get_dataloader(train_ds, batch_size=args.batch_size, shuffle=True)
+ val_loader = (
+ get_dataloader(val_ds, batch_size=args.batch_size, shuffle=False)
+ if len(val_ds) else None
+ )
+
+ if args.model == "tfm":
+ model = TFMTokenizer(dataset=sample_dataset).to(device)
+ _load_tfm_weights(model, args, run_idx)
+ else:
+ model = ContraWR(dataset=sample_dataset, n_fft=args.n_fft).to(device)
+ print(" Training ContraWR...")
+ trainer_tmp = Trainer(model=model, device=device, enable_logging=False)
+ trainer_tmp.train(
+ train_dataloader=train_loader,
+ val_dataloader=val_loader,
+ epochs=epochs,
+ monitor="accuracy" if val_loader is not None else None,
+ )
+ trainer = Trainer(model=model, device=device, enable_logging=False)
+
+ # Base model metrics — computed once, shared across all alphas
+ y_true_base, y_prob_base, _ = trainer.inference(test_loader)
+ base_metrics = get_metrics_fn("multiclass")(
+ y_true_base, y_prob_base, metrics=["accuracy", "f1_weighted"]
+ )
+
+ # Calibration + evaluation — fast; loop over every alpha
+ results = {}
+ for alpha in alphas:
+ print(f" Calibrating LABEL predictor (alpha={alpha})...")
+ label_predictor = LABEL(model=model, alpha=float(alpha))
+ label_predictor.calibrate(cal_dataset=cal_ds)
+
+ y_true, y_prob, _, extra = Trainer(model=label_predictor).inference(
+ test_loader, additional_outputs=["y_predset"]
+ )
+ conf_metrics = get_metrics_fn("multiclass")(
+ y_true, y_prob,
+ metrics=["accuracy", "miscoverage_ps"],
+ y_predset=extra["y_predset"],
+ )
+
+ predset = extra["y_predset"]
+ predset_t = torch.tensor(predset) if isinstance(predset, np.ndarray) else predset
+ avg_set_size = predset_t.float().sum(dim=1).mean().item()
+
+ miscoverage = conf_metrics["miscoverage_ps"]
+ if isinstance(miscoverage, np.ndarray):
+ miscoverage = float(miscoverage.item() if miscoverage.size == 1 else miscoverage.mean())
+ else:
+ miscoverage = float(miscoverage)
+
+ results[alpha] = {
+ "accuracy": float(base_metrics["accuracy"]),
+ "f1_weighted": float(base_metrics["f1_weighted"]),
+ "coverage": 1.0 - miscoverage,
+ "miscoverage": miscoverage,
+ "avg_set_size": avg_set_size,
+ }
+ return results
+
+
+def _print_single_run_results(metrics: dict, alpha: float) -> None:
+ print("\nLABEL Results:")
+ print(f" Accuracy: {metrics['accuracy']:.4f}")
+ print(f" F1 (weighted): {metrics['f1_weighted']:.4f}")
+ print(f" Empirical coverage: {metrics['coverage']:.4f}")
+ print(f" Empirical miscoverage: {metrics['miscoverage']:.4f}")
+ print(f" Average set size: {metrics['avg_set_size']:.2f}")
+ print(f" Target coverage: {1 - alpha:.0%} (alpha={alpha})")
+
+
+def _print_multi_seed_summary(
+ all_metrics: list, run_seeds: list, alpha: float, n_test: int
+) -> None:
+ accs = np.array([m["accuracy"] for m in all_metrics])
+ f1s = np.array([m["f1_weighted"] for m in all_metrics])
+ coverages = np.array([m["coverage"] for m in all_metrics])
+ miscovs = np.array([m["miscoverage"] for m in all_metrics])
+ set_sizes = np.array([m["avg_set_size"] for m in all_metrics])
+ n_runs = len(all_metrics)
+
+ print("\n" + "=" * 80)
+ print(f"Per-run results — alpha={alpha} (LABEL, fixed test set = TUH eval partition)")
+ print("=" * 80)
+ print(f" {'Run':<4} {'Seed':<6} {'Accuracy':<10} {'F1-Wt':<10} "
+ f"{'Coverage':<10} {'Miscoverage':<12} {'Avg set size':<12}")
+ print(" " + "-" * 68)
+ for i in range(n_runs):
+ m = all_metrics[i]
+ print(f" {i+1:<4} {run_seeds[i]:<6} {m['accuracy']:<10.4f} "
+ f"{m['f1_weighted']:<10.4f} {m['coverage']:<10.4f} "
+ f"{m['miscoverage']:<12.4f} {m['avg_set_size']:<12.2f}")
+
+ print("\n" + "=" * 80)
+ print(f"Summary — alpha={alpha} (mean \u00b1 std over {n_runs} runs, fixed test set)")
+ print(" Method: LABEL")
+ print("=" * 80)
+ print(f" Accuracy: {accs.mean():.4f} \u00b1 {accs.std():.4f}")
+ print(f" F1 (weighted): {f1s.mean():.4f} \u00b1 {f1s.std():.4f}")
+ print(f" Empirical coverage: {coverages.mean():.4f} \u00b1 {coverages.std():.4f}")
+ print(f" Empirical miscoverage: {miscovs.mean():.4f} \u00b1 {miscovs.std():.4f}")
+ print(f" Average set size: {set_sizes.mean():.2f} \u00b1 {set_sizes.std():.2f}")
+ print(f" Target coverage: {1 - alpha:.0%} (alpha={alpha})")
+ print(f" Test set size: {n_test} (fixed across runs)")
+ print(f" Run seeds: {run_seeds}")
+ print("\n--- Min / Max (across runs) ---")
+ print(f" Coverage: [{coverages.min():.4f}, {coverages.max():.4f}]")
+ print(f" Set size: [{set_sizes.min():.2f}, {set_sizes.max():.2f}]")
+ print(f" Accuracy: [{accs.min():.4f}, {accs.max():.4f}]")
+
+def _main(args: argparse.Namespace) -> None:
device = args.device or ("cuda:0" if torch.cuda.is_available() else "cpu")
root = Path(args.root)
if not root.exists():
@@ -80,92 +321,111 @@ def main() -> None:
"Pass --root to point to your downloaded TUEV edf/ directory."
)
+ epochs = 2 if args.quick_test else args.epochs
+ quick_test_max_samples = 2000
+ if args.quick_test:
+ print("*** QUICK TEST MODE (dev=True, 2 epochs, max 2000 samples) ***")
+
+ # -------------------------------------------------------------------------
+ # STEP 1: Load dataset ONCE — shared across all seeds
+ # -------------------------------------------------------------------------
print("=" * 80)
- print("STEP 1: Load TUEV + build task dataset")
+ print("STEP 1: Load TUEV + build task dataset (shared across all seeds)")
print("=" * 80)
- dataset = TUEVDataset(root=str(root), subset=args.subset)
- sample_dataset = dataset.set_task(EEGEventsTUEV())
-
- print(f"Task samples: {len(sample_dataset)}")
- print(f"Input schema: {sample_dataset.input_schema}")
+ dataset = TUEVDataset(root=str(root), subset=args.subset, dev=args.quick_test)
+ sample_dataset = dataset.set_task(EEGEventsTUEV(normalization="95th_percentile"), num_workers=16)
+ if args.quick_test and len(sample_dataset) > quick_test_max_samples:
+ sample_dataset = sample_dataset.subset(range(quick_test_max_samples))
+ print(f"Capped to {quick_test_max_samples} samples for quick-test.")
+ print(f"Task samples: {len(sample_dataset)}")
+ print(f"Input schema: {sample_dataset.input_schema}")
print(f"Output schema: {sample_dataset.output_schema}")
-
if len(sample_dataset) == 0:
raise RuntimeError("No samples produced. Verify TUEV root/subset/task.")
+ # -------------------------------------------------------------------------
+ # STEP 2: Extract the fixed test set ONCE
+ # The TUH eval partition is deterministic — any seed produces the same test set.
+ # -------------------------------------------------------------------------
print("\n" + "=" * 80)
- print("STEP 2: Split train/val/cal/test")
+ print("STEP 2: Extract fixed test set (TUH eval partition — same for all seeds)")
print("=" * 80)
- train_ds, val_ds, cal_ds, test_ds = split_by_sample_conformal(
- dataset=sample_dataset, ratios=list(args.ratios), seed=args.seed
+ _, _, _, test_ds = _do_split(
+ sample_dataset, args.ratios, args.seed, args.split_type
)
- print(f"Train: {len(train_ds)}")
- print(f"Val: {len(val_ds)}")
- print(f"Cal: {len(cal_ds)}")
- print(f"Test: {len(test_ds)}")
-
- train_loader = get_dataloader(train_ds, batch_size=args.batch_size, shuffle=True)
- val_loader = get_dataloader(val_ds, batch_size=args.batch_size, shuffle=False) if len(val_ds) else None
+ if len(test_ds) == 0 and args.quick_test:
+ # dev mode only loads train-partition patients, so eval partition is empty.
+ # Fall back to a random 20% hold-out for smoke-testing the pipeline end-to-end.
+ print(" [quick-test] TUH eval partition empty in dev mode — using random 20% as test set.")
+ _, _, _, test_ds = split_by_sample_conformal(
+ dataset=sample_dataset, ratios=[0.6, 0.1, 0.1, 0.2], seed=args.seed
+ )
test_loader = get_dataloader(test_ds, batch_size=args.batch_size, shuffle=False)
+ print(f"Test: {len(test_ds)} (fixed)")
- print("\n" + "=" * 80)
- print("STEP 3: Train ContraWR")
- print("=" * 80)
- model = ContraWR(dataset=sample_dataset, n_fft=args.n_fft).to(device)
- trainer = Trainer(model=model, device=device, enable_logging=False)
+ # -------------------------------------------------------------------------
+ # Determine run seeds and alphas
+ # -------------------------------------------------------------------------
+ if args.seeds is not None:
+ run_seeds = [int(s.strip()) for s in args.seeds.split(",")]
+ else:
+ run_seeds = [args.seed + i for i in range(args.n_seeds)]
- trainer.train(
- train_dataloader=train_loader,
- val_dataloader=val_loader,
- epochs=args.epochs,
- monitor="accuracy" if val_loader is not None else None,
- )
+ alphas = [float(a.strip()) for a in args.alphas.split(",")] if args.alphas else [args.alpha]
- print("\nBase model performance on test set:")
- y_true_base, y_prob_base, _loss_base = trainer.inference(test_loader)
- base_metrics = get_metrics_fn("multiclass")(y_true_base, y_prob_base, metrics=["accuracy", "f1_weighted"])
- for metric, value in base_metrics.items():
- print(f" {metric}: {value:.4f}")
+ use_multi_seed = len(run_seeds) > 1
+ print(f"\nRun config: {'multi-seed (' + str(len(run_seeds)) + ' runs)' if use_multi_seed else 'single run'}")
+ print(f"Seeds: {run_seeds}, alphas={alphas}")
- print("\n" + "=" * 80)
- print("STEP 4: Conventional Conformal Prediction (LABEL)")
- print("=" * 80)
- print(f"Target miscoverage alpha: {args.alpha} (target coverage {1 - args.alpha:.0%})")
+ # -------------------------------------------------------------------------
+ # STEP 3+: Train once per seed; calibrate for every alpha (fast)
+ # -------------------------------------------------------------------------
+ all_metrics = {alpha: [] for alpha in alphas}
+ for run_i, run_seed in enumerate(run_seeds):
+ print("\n" + "=" * 80)
+ if use_multi_seed:
+ print(f"Run {run_i + 1} / {len(run_seeds)} (seed={run_seed})")
+ else:
+ print(f"STEP 3–4: Train + Conformal Calibration (seed={run_seed})")
+ print("=" * 80)
- label_predictor = LABEL(model=model, alpha=float(args.alpha))
- print("Calibrating LABEL predictor...")
- label_predictor.calibrate(cal_dataset=cal_ds)
+ seed_results = _run_one_seed(
+ args, sample_dataset, test_ds, test_loader, device, epochs, run_seed, alphas,
+ run_idx=run_i,
+ )
+ for alpha in alphas:
+ all_metrics[alpha].append(seed_results[alpha])
- print("Evaluating LABEL predictor on test set...")
- y_true, y_prob, _loss, extra = Trainer(model=label_predictor).inference(
- test_loader, additional_outputs=["y_predset"]
- )
+ if use_multi_seed:
+ m = seed_results[alphas[0]]
+ print(f" [Run {run_i + 1} result (alpha={alphas[0]})] "
+ f"acc={m['accuracy']:.4f}, f1={m['f1_weighted']:.4f}, "
+ f"cov={m['coverage']:.4f}, set_size={m['avg_set_size']:.2f}")
- label_metrics = get_metrics_fn("multiclass")(
- y_true,
- y_prob,
- metrics=["accuracy", "miscoverage_ps"],
- y_predset=extra["y_predset"],
- )
+ for alpha in alphas:
+ if not use_multi_seed:
+ _print_single_run_results(all_metrics[alpha][0], alpha)
+ else:
+ _print_multi_seed_summary(all_metrics[alpha], run_seeds, alpha, len(test_ds))
- predset = extra["y_predset"]
- if isinstance(predset, np.ndarray):
- predset_t = torch.tensor(predset)
- else:
- predset_t = predset
- avg_set_size = predset_t.float().sum(dim=1).mean().item()
- miscoverage = label_metrics["miscoverage_ps"]
- if isinstance(miscoverage, np.ndarray):
- miscoverage = float(miscoverage.item() if miscoverage.size == 1 else miscoverage.mean())
- else:
- miscoverage = float(miscoverage)
+def main() -> None:
+ args = parse_args()
- print("\nLABEL Results:")
- print(f" Accuracy: {label_metrics['accuracy']:.4f}")
- print(f" Empirical miscoverage: {miscoverage:.4f}")
- print(f" Empirical coverage: {1 - miscoverage:.4f}")
- print(f" Average set size: {avg_set_size:.2f}")
+ orig_stdout, orig_stderr = sys.stdout, sys.stderr
+ log_file = None
+ if args.log_file:
+ log_file = open(args.log_file, "w", encoding="utf-8")
+ sys.stdout = _Tee(orig_stdout, log_file)
+ sys.stderr = _Tee(orig_stderr, log_file)
+
+ try:
+ _main(args)
+ finally:
+ if log_file is not None:
+ sys.stdout = orig_stdout
+ sys.stderr = orig_stderr
+ log_file.close()
if __name__ == "__main__":
diff --git a/examples/conformal_eeg/tuev_covariate_shift_conformal.py b/examples/conformal_eeg/tuev_covariate_shift_conformal.py
index 41a356a79..d1bcba20a 100644
--- a/examples/conformal_eeg/tuev_covariate_shift_conformal.py
+++ b/examples/conformal_eeg/tuev_covariate_shift_conformal.py
@@ -1,33 +1,59 @@
"""Covariate-Shift Adaptive Conformal Prediction (CovariateLabel) on TUEV EEG Events using ContraWR.
This script:
-1) Loads the TUEV dataset and applies the EEGEventsTUEV task.
-2) Splits into train/val/cal/test using split conformal protocol.
-3) Trains a ContraWR model.
-4) Extracts embeddings for calibration and test splits using embed=True.
-5) Calibrates a CovariateLabel prediction-set predictor (KDE-based shift correction).
-6) Evaluates prediction-set coverage/miscoverage and efficiency on the test split.
-
-Example (from repo root):
+1) Loads the TUEV dataset and applies the EEGEventsTUEV task (once, shared across all seeds).
+2) Extracts the fixed test set (TUH eval partition — never changes across seeds).
+3) For each seed: splits the TUH train partition into train/val/cal, trains ContraWR,
+ extracts cal and test embeddings, calibrates a CovariateLabel predictor, and
+ evaluates on the fixed test set.
+4) Reports per-run results and mean ± std summary across all seeds.
+
+Single-seed usage (from repo root):
python examples/conformal_eeg/tuev_covariate_shift_conformal.py --root downloads/tuev/v2.0.1/edf
+Multi-seed usage (recommended for papers):
+ python examples/conformal_eeg/tuev_covariate_shift_conformal.py \\
+ --root downloads/tuev/v2.0.1/edf --n-seeds 5 --seed 42 --alpha 0.1 \\
+ --log-file tuev_covariate_alpha0.1_5seeds.log
+
Notes:
-- CovariateLabel requires access to test embeddings/features to estimate density ratios.
+- CovariateLabel requires access to test embeddings to estimate density ratios.
+- Test embeddings are recomputed each seed since the model changes.
"""
from __future__ import annotations
import argparse
+import os
import random
+import sys
from pathlib import Path
import numpy as np
import torch
+
+class _Tee:
+ """Writes to both a stream and a file simultaneously."""
+
+ def __init__(self, stream, file):
+ self._stream = stream
+ self._file = file
+
+ def write(self, data):
+ self._stream.write(data)
+ self._file.write(data)
+ self._file.flush()
+
+ def flush(self):
+ self._stream.flush()
+ self._file.flush()
+
+
from pyhealth.calib.predictionset.covariate import CovariateLabel
from pyhealth.calib.utils import extract_embeddings
-from pyhealth.datasets import TUEVDataset, get_dataloader, split_by_sample_conformal
-from pyhealth.models import ContraWR
+from pyhealth.datasets import TUEVDataset, get_dataloader, split_by_patient_conformal_tuh, split_by_sample_conformal_tuh, split_by_sample_conformal
+from pyhealth.models import ContraWR, TFMTokenizer
from pyhealth.tasks import EEGEventsTUEV
from pyhealth.trainer import Trainer, get_metrics_fn
@@ -39,32 +65,109 @@ def parse_args() -> argparse.Namespace:
parser.add_argument(
"--root",
type=str,
- default="downloads/tuev/v2.0.1/edf",
+ default="/srv/local/data/TUH/tuh_eeg_events/v2.0.0/edf",
help="Path to TUEV edf/ folder.",
)
parser.add_argument("--subset", type=str, default="both", choices=["train", "eval", "both"])
- parser.add_argument("--seed", type=int, default=42)
+ parser.add_argument(
+ "--seed",
+ type=int,
+ default=42,
+ help="Base seed. With --n-seeds N, runs seeds seed, seed+1, ..., seed+N-1.",
+ )
parser.add_argument("--batch-size", type=int, default=32)
- parser.add_argument("--epochs", type=int, default=3)
- parser.add_argument("--alpha", type=float, default=0.1, help="Miscoverage rate (e.g., 0.1 => 90% target coverage).")
+ parser.add_argument("--epochs", type=int, default=20)
+ parser.add_argument(
+ "--alpha", type=float, default=0.1,
+ help="Miscoverage rate (e.g., 0.1 => 90% target coverage).",
+ )
+ parser.add_argument(
+ "--alphas", type=str, default=None,
+ help="Comma-separated miscoverage rates, e.g. '0.2,0.1,0.05,0.01'. Overrides --alpha.",
+ )
parser.add_argument(
"--ratios",
type=float,
- nargs=4,
- default=(0.6, 0.1, 0.15, 0.15),
- metavar=("TRAIN", "VAL", "CAL", "TEST"),
- help="Split ratios for train/val/cal/test. Must sum to 1.0.",
+ nargs=3,
+ default=(0.6, 0.2, 0.2),
+ metavar=("TRAIN", "VAL", "CAL"),
+ help="Ratios for splitting the TUH train partition into train/val/cal. "
+ "Must sum to 1.0. Test is fixed as the TUH eval partition.",
)
parser.add_argument("--n-fft", type=int, default=128, help="STFT FFT size used by ContraWR.")
parser.add_argument(
- "--device",
+ "--model", type=str, default="contrawr", choices=["contrawr", "tfm"],
+ help="Backbone model: 'contrawr' (default) or 'tfm' (TFMTokenizer).",
+ )
+ parser.add_argument(
+ "--device", type=str, default=None,
+ help="Device string, e.g. 'cuda:0' or 'cpu'. Defaults to auto-detect.",
+ )
+ parser.add_argument(
+ "--n-seeds",
+ type=int,
+ default=1,
+ help="Number of seeds to run sequentially for mean±std reporting. "
+ "Seeds are seed, seed+1, ..., seed+n_seeds-1.",
+ )
+ parser.add_argument(
+ "--seeds",
type=str,
default=None,
- help="Device string, e.g. 'cuda:0' or 'cpu'. Defaults to auto-detect.",
+ help="Explicit comma-separated seeds, e.g. '42,43,44,45,46'. "
+ "Overrides --seed and --n-seeds.",
+ )
+ parser.add_argument(
+ "--log-file", type=str, default=None,
+ help="Path to log file. Stdout and stderr are teed to this file.",
+ )
+ parser.add_argument(
+ "--quick-test",
+ action="store_true",
+ help="Smoke test: dev=True, max 2000 samples, 2 epochs.",
+ )
+ parser.add_argument(
+ "--weights-dir",
+ type=str,
+ default="weightfiles/TFM_Tokenizer_multiple_finetuned_on_TUEV",
+ help="Root folder of fine-tuned TFM classifier checkpoints (only with --model tfm).",
+ )
+ parser.add_argument(
+ "--tokenizer-weights",
+ type=str,
+ default="weightfiles/tfm_tokenizer_last.pth",
+ help="Path to the pre-trained TFM tokenizer weights (only with --model tfm).",
+ )
+ parser.add_argument(
+ "--split-type",
+ type=str,
+ default="patient",
+ choices=["patient", "sample"],
+ help="Split strategy: 'patient' (default, patient-level, no leakage) or "
+ "'sample' (original sample-level, for comparison).",
)
return parser.parse_args()
+def _do_split(dataset, ratios, seed, split_type):
+ """Dispatch to the correct TUH split function based on split_type."""
+ if split_type == "patient":
+ return split_by_patient_conformal_tuh(dataset=dataset, ratios=list(ratios), seed=seed)
+ else:
+ return split_by_sample_conformal_tuh(dataset=dataset, ratios=list(ratios), seed=seed)
+
+
+def _load_tfm_weights(model, args, run_idx: int) -> None:
+ """Load pre-trained tokenizer + fine-tuned classifier for run_idx (0-based)."""
+ base = os.path.basename(args.weights_dir)
+ classifier_path = os.path.join(args.weights_dir, f"{base}_{run_idx + 1}", "best_model.pth")
+ print(f" Loading TFM weights (run {run_idx + 1}): {classifier_path}")
+ model.load_pretrained_weights(
+ tokenizer_checkpoint_path=args.tokenizer_weights,
+ classifier_checkpoint_path=classifier_path,
+ )
+
+
def set_seed(seed: int) -> None:
random.seed(seed)
np.random.seed(seed)
@@ -73,10 +176,156 @@ def set_seed(seed: int) -> None:
torch.cuda.manual_seed_all(seed)
-def main() -> None:
- args = parse_args()
- set_seed(args.seed)
+def _run_one_seed(
+ args,
+ sample_dataset,
+ test_ds,
+ test_loader,
+ device: str,
+ epochs: int,
+ run_seed: int,
+ alphas: list,
+ run_idx: int = 0,
+) -> dict:
+ """Train model + calibrate CovariateLabel for one seed across all alphas.
+
+ Training, embedding extraction, and base inference are done once; calibration
+ loops over alphas (fast — only likelihood-ratio weights/threshold recomputed).
+
+ Returns {alpha: metrics_dict} where metrics_dict has keys:
+ accuracy, f1_weighted, coverage, miscoverage, avg_set_size
+ """
+ set_seed(run_seed)
+
+ train_ds, val_ds, cal_ds, _ = _do_split(
+ sample_dataset, args.ratios, run_seed, args.split_type
+ )
+ print(f" Split — Train: {len(train_ds)}, Val: {len(val_ds)}, "
+ f"Cal: {len(cal_ds)}, Test: {len(test_ds)} (fixed)")
+
+ train_loader = get_dataloader(train_ds, batch_size=args.batch_size, shuffle=True)
+ val_loader = (
+ get_dataloader(val_ds, batch_size=args.batch_size, shuffle=False)
+ if len(val_ds) else None
+ )
+
+ if args.model == "tfm":
+ model = TFMTokenizer(dataset=sample_dataset).to(device)
+ _load_tfm_weights(model, args, run_idx)
+ else:
+ model = ContraWR(dataset=sample_dataset, n_fft=args.n_fft).to(device)
+ print(" Training ContraWR...")
+ trainer_tmp = Trainer(model=model, device=device, enable_logging=False)
+ trainer_tmp.train(
+ train_dataloader=train_loader,
+ val_dataloader=val_loader,
+ epochs=epochs,
+ monitor="accuracy" if val_loader is not None else None,
+ )
+ trainer = Trainer(model=model, device=device, enable_logging=False)
+ # Base model metrics — computed once, shared across all alphas
+ y_true_base, y_prob_base, _ = trainer.inference(test_loader)
+ base_metrics = get_metrics_fn("multiclass")(
+ y_true_base, y_prob_base, metrics=["accuracy", "f1_weighted"]
+ )
+
+ # Extract embeddings once — reused for every alpha
+ print(" Extracting embeddings for calibration and test splits...")
+ cal_embeddings = extract_embeddings(model, cal_ds, batch_size=args.batch_size, device=device)
+ test_embeddings = extract_embeddings(model, test_ds, batch_size=args.batch_size, device=device)
+
+ # Calibration + evaluation — fast; loop over every alpha
+ results = {}
+ for alpha in alphas:
+ print(f" Calibrating CovariateLabel predictor (alpha={alpha})...")
+ cov_predictor = CovariateLabel(model=model, alpha=float(alpha))
+ cov_predictor.calibrate(
+ cal_dataset=cal_ds,
+ cal_embeddings=cal_embeddings,
+ test_embeddings=test_embeddings,
+ )
+
+ y_true, y_prob, _, extra = Trainer(model=cov_predictor).inference(
+ test_loader, additional_outputs=["y_predset"]
+ )
+ conf_metrics = get_metrics_fn("multiclass")(
+ y_true, y_prob,
+ metrics=["accuracy", "miscoverage_ps"],
+ y_predset=extra["y_predset"],
+ )
+
+ predset = extra["y_predset"]
+ predset_t = torch.tensor(predset) if isinstance(predset, np.ndarray) else predset
+ avg_set_size = predset_t.float().sum(dim=1).mean().item()
+
+ miscoverage = conf_metrics["miscoverage_ps"]
+ if isinstance(miscoverage, np.ndarray):
+ miscoverage = float(miscoverage.item() if miscoverage.size == 1 else miscoverage.mean())
+ else:
+ miscoverage = float(miscoverage)
+
+ results[alpha] = {
+ "accuracy": float(base_metrics["accuracy"]),
+ "f1_weighted": float(base_metrics["f1_weighted"]),
+ "coverage": 1.0 - miscoverage,
+ "miscoverage": miscoverage,
+ "avg_set_size": avg_set_size,
+ }
+ return results
+
+
+def _print_single_run_results(metrics: dict, alpha: float) -> None:
+ print("\nCovariateLabel Results:")
+ print(f" Accuracy: {metrics['accuracy']:.4f}")
+ print(f" F1 (weighted): {metrics['f1_weighted']:.4f}")
+ print(f" Empirical coverage: {metrics['coverage']:.4f}")
+ print(f" Empirical miscoverage: {metrics['miscoverage']:.4f}")
+ print(f" Average set size: {metrics['avg_set_size']:.2f}")
+ print(f" Target coverage: {1 - alpha:.0%} (alpha={alpha})")
+
+
+def _print_multi_seed_summary(
+ all_metrics: list, run_seeds: list, alpha: float, n_test: int
+) -> None:
+ accs = np.array([m["accuracy"] for m in all_metrics])
+ f1s = np.array([m["f1_weighted"] for m in all_metrics])
+ coverages = np.array([m["coverage"] for m in all_metrics])
+ miscovs = np.array([m["miscoverage"] for m in all_metrics])
+ set_sizes = np.array([m["avg_set_size"] for m in all_metrics])
+ n_runs = len(all_metrics)
+
+ print("\n" + "=" * 80)
+ print(f"Per-run results — alpha={alpha} (CovariateLabel, fixed test set = TUH eval partition)")
+ print("=" * 80)
+ print(f" {'Run':<4} {'Seed':<6} {'Accuracy':<10} {'F1-Wt':<10} "
+ f"{'Coverage':<10} {'Miscoverage':<12} {'Avg set size':<12}")
+ print(" " + "-" * 68)
+ for i in range(n_runs):
+ m = all_metrics[i]
+ print(f" {i+1:<4} {run_seeds[i]:<6} {m['accuracy']:<10.4f} "
+ f"{m['f1_weighted']:<10.4f} {m['coverage']:<10.4f} "
+ f"{m['miscoverage']:<12.4f} {m['avg_set_size']:<12.2f}")
+
+ print("\n" + "=" * 80)
+ print(f"Summary — alpha={alpha} (mean \u00b1 std over {n_runs} runs, fixed test set)")
+ print(" Method: CovariateLabel")
+ print("=" * 80)
+ print(f" Accuracy: {accs.mean():.4f} \u00b1 {accs.std():.4f}")
+ print(f" F1 (weighted): {f1s.mean():.4f} \u00b1 {f1s.std():.4f}")
+ print(f" Empirical coverage: {coverages.mean():.4f} \u00b1 {coverages.std():.4f}")
+ print(f" Empirical miscoverage: {miscovs.mean():.4f} \u00b1 {miscovs.std():.4f}")
+ print(f" Average set size: {set_sizes.mean():.2f} \u00b1 {set_sizes.std():.2f}")
+ print(f" Target coverage: {1 - alpha:.0%} (alpha={alpha})")
+ print(f" Test set size: {n_test} (fixed across runs)")
+ print(f" Run seeds: {run_seeds}")
+ print("\n--- Min / Max (across runs) ---")
+ print(f" Coverage: [{coverages.min():.4f}, {coverages.max():.4f}]")
+ print(f" Set size: [{set_sizes.min():.2f}, {set_sizes.max():.2f}]")
+ print(f" Accuracy: [{accs.min():.4f}, {accs.max():.4f}]")
+
+
+def _main(args: argparse.Namespace) -> None:
device = args.device or ("cuda:0" if torch.cuda.is_available() else "cpu")
root = Path(args.root)
if not root.exists():
@@ -85,104 +334,108 @@ def main() -> None:
"Pass --root to point to your downloaded TUEV edf/ directory."
)
+ epochs = 2 if args.quick_test else args.epochs
+ quick_test_max_samples = 2000
+ if args.quick_test:
+ print("*** QUICK TEST MODE (dev=True, 2 epochs, max 2000 samples) ***")
+
+ # -------------------------------------------------------------------------
+ # STEP 1: Load dataset ONCE — shared across all seeds
+ # -------------------------------------------------------------------------
print("=" * 80)
- print("STEP 1: Load TUEV + build task dataset")
+ print("STEP 1: Load TUEV + build task dataset (shared across all seeds)")
print("=" * 80)
- dataset = TUEVDataset(root=str(root), subset=args.subset)
- sample_dataset = dataset.set_task(EEGEventsTUEV())
-
- print(f"Task samples: {len(sample_dataset)}")
- print(f"Input schema: {sample_dataset.input_schema}")
+ dataset = TUEVDataset(root=str(root), subset=args.subset, dev=args.quick_test)
+ sample_dataset = dataset.set_task(EEGEventsTUEV(normalization="95th_percentile"), num_workers=16)
+ if args.quick_test and len(sample_dataset) > quick_test_max_samples:
+ sample_dataset = sample_dataset.subset(range(quick_test_max_samples))
+ print(f"Capped to {quick_test_max_samples} samples for quick-test.")
+ print(f"Task samples: {len(sample_dataset)}")
+ print(f"Input schema: {sample_dataset.input_schema}")
print(f"Output schema: {sample_dataset.output_schema}")
-
if len(sample_dataset) == 0:
raise RuntimeError("No samples produced. Verify TUEV root/subset/task.")
+ # -------------------------------------------------------------------------
+ # STEP 2: Extract the fixed test set ONCE
+ # -------------------------------------------------------------------------
print("\n" + "=" * 80)
- print("STEP 2: Split train/val/cal/test")
+ print("STEP 2: Extract fixed test set (TUH eval partition — same for all seeds)")
print("=" * 80)
- train_ds, val_ds, cal_ds, test_ds = split_by_sample_conformal(
- dataset=sample_dataset, ratios=list(args.ratios), seed=args.seed
+ _, _, _, test_ds = _do_split(
+ sample_dataset, args.ratios, args.seed, args.split_type
)
- print(f"Train: {len(train_ds)}")
- print(f"Val: {len(val_ds)}")
- print(f"Cal: {len(cal_ds)}")
- print(f"Test: {len(test_ds)}")
-
- train_loader = get_dataloader(train_ds, batch_size=args.batch_size, shuffle=True)
- val_loader = get_dataloader(val_ds, batch_size=args.batch_size, shuffle=False) if len(val_ds) else None
+ if len(test_ds) == 0 and args.quick_test:
+ print(" [quick-test] TUH eval partition empty in dev mode — using random 20% as test set.")
+ _, _, _, test_ds = split_by_sample_conformal(
+ dataset=sample_dataset, ratios=[0.6, 0.1, 0.1, 0.2], seed=args.seed
+ )
test_loader = get_dataloader(test_ds, batch_size=args.batch_size, shuffle=False)
+ print(f"Test: {len(test_ds)} (fixed)")
- print("\n" + "=" * 80)
- print("STEP 3: Train ContraWR")
- print("=" * 80)
- model = ContraWR(dataset=sample_dataset, n_fft=args.n_fft).to(device)
- trainer = Trainer(model=model, device=device, enable_logging=False)
-
- trainer.train(
- train_dataloader=train_loader,
- val_dataloader=val_loader,
- epochs=args.epochs,
- monitor="accuracy" if val_loader is not None else None,
- )
-
- print("\nBase model performance on test set:")
- y_true_base, y_prob_base, _loss_base = trainer.inference(test_loader)
- base_metrics = get_metrics_fn("multiclass")(y_true_base, y_prob_base, metrics=["accuracy", "f1_weighted"])
- for metric, value in base_metrics.items():
- print(f" {metric}: {value:.4f}")
-
- print("\n" + "=" * 80)
- print("STEP 4: Covariate Shift Adaptive Conformal Prediction (CovariateLabel)")
- print("=" * 80)
- print(f"Target miscoverage alpha: {args.alpha} (target coverage {1 - args.alpha:.0%})")
-
- print("Extracting embeddings for calibration split...")
- cal_embeddings = extract_embeddings(model, cal_ds, batch_size=args.batch_size, device=device)
- print(f" cal_embeddings shape: {cal_embeddings.shape}")
-
- print("Extracting embeddings for test split...")
- test_embeddings = extract_embeddings(model, test_ds, batch_size=args.batch_size, device=device)
- print(f" test_embeddings shape: {test_embeddings.shape}")
-
- cov_predictor = CovariateLabel(model=model, alpha=float(args.alpha))
- print("Calibrating CovariateLabel predictor (fits KDEs internally)...")
- cov_predictor.calibrate(
- cal_dataset=cal_ds,
- cal_embeddings=cal_embeddings,
- test_embeddings=test_embeddings,
- )
+ # -------------------------------------------------------------------------
+ # Determine run seeds
+ # -------------------------------------------------------------------------
+ if args.seeds is not None:
+ run_seeds = [int(s.strip()) for s in args.seeds.split(",")]
+ else:
+ run_seeds = [args.seed + i for i in range(args.n_seeds)]
+
+ alphas = [float(a.strip()) for a in args.alphas.split(",")] if args.alphas else [args.alpha]
+
+ use_multi_seed = len(run_seeds) > 1
+ print(f"\nRun config: {'multi-seed (' + str(len(run_seeds)) + ' runs)' if use_multi_seed else 'single run'}")
+ print(f"Seeds: {run_seeds}, alphas={alphas}")
+
+ # -------------------------------------------------------------------------
+ # STEP 3+: Train once per seed; calibrate for every alpha (fast)
+ # -------------------------------------------------------------------------
+ all_metrics = {alpha: [] for alpha in alphas}
+ for run_i, run_seed in enumerate(run_seeds):
+ print("\n" + "=" * 80)
+ if use_multi_seed:
+ print(f"Run {run_i + 1} / {len(run_seeds)} (seed={run_seed})")
+ else:
+ print(f"STEP 3–4: Train + Conformal Calibration (seed={run_seed})")
+ print("=" * 80)
+
+ seed_results = _run_one_seed(
+ args, sample_dataset, test_ds, test_loader, device, epochs, run_seed, alphas,
+ run_idx=run_i,
+ )
+ for alpha in alphas:
+ all_metrics[alpha].append(seed_results[alpha])
- print("Evaluating CovariateLabel predictor on test set...")
- y_true, y_prob, _loss, extra = Trainer(model=cov_predictor).inference(
- test_loader, additional_outputs=["y_predset"]
- )
+ if use_multi_seed:
+ m = seed_results[alphas[0]]
+ print(f" [Run {run_i + 1} result (alpha={alphas[0]})] "
+ f"acc={m['accuracy']:.4f}, f1={m['f1_weighted']:.4f}, "
+ f"cov={m['coverage']:.4f}, set_size={m['avg_set_size']:.2f}")
- cov_metrics = get_metrics_fn("multiclass")(
- y_true,
- y_prob,
- metrics=["accuracy", "miscoverage_ps"],
- y_predset=extra["y_predset"],
- )
+ for alpha in alphas:
+ if not use_multi_seed:
+ _print_single_run_results(all_metrics[alpha][0], alpha)
+ else:
+ _print_multi_seed_summary(all_metrics[alpha], run_seeds, alpha, len(test_ds))
- predset = extra["y_predset"]
- if isinstance(predset, np.ndarray):
- predset_t = torch.tensor(predset)
- else:
- predset_t = predset
- avg_set_size = predset_t.float().sum(dim=1).mean().item()
- miscoverage = cov_metrics["miscoverage_ps"]
- if isinstance(miscoverage, np.ndarray):
- miscoverage = float(miscoverage.item() if miscoverage.size == 1 else miscoverage.mean())
- else:
- miscoverage = float(miscoverage)
+def main() -> None:
+ args = parse_args()
- print("\nCovariateLabel Results:")
- print(f" Accuracy: {cov_metrics['accuracy']:.4f}")
- print(f" Empirical miscoverage: {miscoverage:.4f}")
- print(f" Empirical coverage: {1 - miscoverage:.4f}")
- print(f" Average set size: {avg_set_size:.2f}")
+ orig_stdout, orig_stderr = sys.stdout, sys.stderr
+ log_file = None
+ if args.log_file:
+ log_file = open(args.log_file, "w", encoding="utf-8")
+ sys.stdout = _Tee(orig_stdout, log_file)
+ sys.stderr = _Tee(orig_stderr, log_file)
+
+ try:
+ _main(args)
+ finally:
+ if log_file is not None:
+ sys.stdout = orig_stdout
+ sys.stderr = orig_stderr
+ log_file.close()
if __name__ == "__main__":
diff --git a/examples/conformal_eeg/tuev_kmeans_conformal.py b/examples/conformal_eeg/tuev_kmeans_conformal.py
index 906883d72..faad50eaa 100644
--- a/examples/conformal_eeg/tuev_kmeans_conformal.py
+++ b/examples/conformal_eeg/tuev_kmeans_conformal.py
@@ -1,25 +1,29 @@
"""K-means Cluster-Based Conformal Prediction (ClusterLabel) on TUEV EEG Events using ContraWR.
This script:
-1) Loads the TUEV dataset and applies the EEGEventsTUEV task.
-2) Splits into train/val/cal/test using split conformal protocol.
-3) Trains a ContraWR model.
-4) Extracts embeddings for training and calibration splits using embed=True.
-5) Calibrates a ClusterLabel prediction-set predictor (K-means clustering).
-6) Evaluates prediction-set coverage/miscoverage and efficiency on the test split.
+1) Loads the TUEV dataset and applies the EEGEventsTUEV task (once, shared across all seeds).
+2) Extracts the fixed test set (TUH eval partition — never changes across seeds).
+3) For each seed: splits the TUH train partition into train/val/cal, trains ContraWR,
+ extracts embeddings, calibrates a ClusterLabel predictor, and evaluates on the fixed test set.
+4) Reports per-run results and mean ± std summary across all seeds.
-Example (from repo root):
- python examples/conformal_eeg/tuev_kmeans_conformal.py --root /srv/local/data/TUH/tuh_eeg_events/v2.0.0/edf --n-clusters 5
- python examples/conformal_eeg/tuev_kmeans_conformal.py --quick-test --log-file quicktest_kmeans.log
+Single-seed usage (from repo root):
+ python examples/conformal_eeg/tuev_kmeans_conformal.py --root downloads/tuev/v2.0.1/edf
+
+Multi-seed usage (recommended for papers):
+ python examples/conformal_eeg/tuev_kmeans_conformal.py \\
+ --root downloads/tuev/v2.0.1/edf --n-seeds 5 --seed 42 --alpha 0.1 \\
+ --log-file tuev_kmeans_alpha0.1_5seeds.log
Notes:
- ClusterLabel uses K-means clustering on embeddings to compute cluster-specific thresholds.
-- Different K values can be tested to find optimal cluster count.
+- Different K values can be tested to find the optimal cluster count.
"""
from __future__ import annotations
import argparse
+import os
import random
import sys
from pathlib import Path
@@ -29,7 +33,7 @@
class _Tee:
- """Writes to both a stream and a file."""
+ """Writes to both a stream and a file simultaneously."""
def __init__(self, stream, file):
self._stream = stream
@@ -47,8 +51,8 @@ def flush(self):
from pyhealth.calib.predictionset.cluster import ClusterLabel
from pyhealth.calib.utils import extract_embeddings
-from pyhealth.datasets import TUEVDataset, get_dataloader, split_by_sample_conformal
-from pyhealth.models import ContraWR
+from pyhealth.datasets import TUEVDataset, get_dataloader, split_by_patient_conformal_tuh, split_by_sample_conformal_tuh, split_by_sample_conformal
+from pyhealth.models import ContraWR, TFMTokenizer
from pyhealth.tasks import EEGEventsTUEV
from pyhealth.trainer import Trainer, get_metrics_fn
@@ -64,45 +68,109 @@ def parse_args() -> argparse.Namespace:
help="Path to TUEV edf/ folder.",
)
parser.add_argument("--subset", type=str, default="both", choices=["train", "eval", "both"])
- parser.add_argument("--seed", type=int, default=42)
+ parser.add_argument(
+ "--seed",
+ type=int,
+ default=42,
+ help="Base seed. With --n-seeds N, runs seeds seed, seed+1, ..., seed+N-1.",
+ )
parser.add_argument("--batch-size", type=int, default=64)
parser.add_argument("--epochs", type=int, default=20)
- parser.add_argument("--alpha", type=float, default=0.1, help="Miscoverage rate (e.g., 0.1 => 90% target coverage).")
+ parser.add_argument(
+ "--alpha", type=float, default=0.1,
+ help="Miscoverage rate (e.g., 0.1 => 90% target coverage).",
+ )
+ parser.add_argument(
+ "--alphas", type=str, default=None,
+ help="Comma-separated miscoverage rates, e.g. '0.2,0.1,0.05,0.01'. Overrides --alpha.",
+ )
parser.add_argument(
"--ratios",
type=float,
- nargs=4,
- default=(0.6, 0.1, 0.15, 0.15),
- metavar=("TRAIN", "VAL", "CAL", "TEST"),
- help="Split ratios for train/val/cal/test. Must sum to 1.0.",
+ nargs=3,
+ default=(0.6, 0.2, 0.2),
+ metavar=("TRAIN", "VAL", "CAL"),
+ help="Ratios for splitting the TUH train partition into train/val/cal. "
+ "Must sum to 1.0. Test is fixed as the TUH eval partition.",
)
parser.add_argument(
- "--n-clusters",
- type=int,
- default=5,
+ "--n-clusters", type=int, default=5,
help="Number of K-means clusters for cluster-specific thresholds.",
)
parser.add_argument("--n-fft", type=int, default=128, help="STFT FFT size used by ContraWR.")
parser.add_argument(
- "--device",
- type=str,
- default=None,
+ "--model", type=str, default="contrawr", choices=["contrawr", "tfm"],
+ help="Backbone model: 'contrawr' (default) or 'tfm' (TFMTokenizer).",
+ )
+ parser.add_argument(
+ "--device", type=str, default=None,
help="Device string, e.g. 'cuda:0' or 'cpu'. Defaults to auto-detect.",
)
parser.add_argument(
- "--log-file",
+ "--n-seeds",
+ type=int,
+ default=1,
+ help="Number of seeds to run sequentially for mean±std reporting. "
+ "Seeds are seed, seed+1, ..., seed+n_seeds-1.",
+ )
+ parser.add_argument(
+ "--seeds",
type=str,
default=None,
+ help="Explicit comma-separated seeds, e.g. '42,43,44,45,46'. "
+ "Overrides --seed and --n-seeds.",
+ )
+ parser.add_argument(
+ "--log-file", type=str, default=None,
help="Path to log file. Stdout and stderr are teed to this file.",
)
parser.add_argument(
"--quick-test",
action="store_true",
- help="Smoke test: dev=True, max 2000 samples, 2 epochs, ~5-10 min.",
+ help="Smoke test: dev=True, max 2000 samples, 2 epochs.",
+ )
+ parser.add_argument(
+ "--weights-dir",
+ type=str,
+ default="weightfiles/TFM_Tokenizer_multiple_finetuned_on_TUEV",
+ help="Root folder of fine-tuned TFM classifier checkpoints (only with --model tfm).",
+ )
+ parser.add_argument(
+ "--tokenizer-weights",
+ type=str,
+ default="weightfiles/tfm_tokenizer_last.pth",
+ help="Path to the pre-trained TFM tokenizer weights (only with --model tfm).",
+ )
+ parser.add_argument(
+ "--split-type",
+ type=str,
+ default="patient",
+ choices=["patient", "sample"],
+ help="Split strategy: 'patient' (default, patient-level, no leakage) or "
+ "'sample' (original sample-level, for comparison).",
)
return parser.parse_args()
+def _do_split(dataset, ratios, seed, split_type):
+ """Dispatch to the correct TUH split function based on split_type."""
+ if split_type == "patient":
+ return split_by_patient_conformal_tuh(dataset=dataset, ratios=list(ratios), seed=seed)
+ else:
+ return split_by_sample_conformal_tuh(dataset=dataset, ratios=list(ratios), seed=seed)
+
+
+def _load_tfm_weights(model, args, run_idx: int) -> None:
+ """Load pre-trained tokenizer + fine-tuned classifier for run_idx (0-based)."""
+ base = os.path.basename(args.weights_dir)
+ classifier_path = os.path.join(args.weights_dir, f"{base}_{run_idx + 1}", "best_model.pth")
+ print(f" Loading TFM weights (run {run_idx + 1}): {classifier_path}")
+ model.load_pretrained_weights(
+ tokenizer_checkpoint_path=args.tokenizer_weights,
+ classifier_checkpoint_path=classifier_path,
+ )
+
+
def set_seed(seed: int) -> None:
random.seed(seed)
np.random.seed(seed)
@@ -111,27 +179,163 @@ def set_seed(seed: int) -> None:
torch.cuda.manual_seed_all(seed)
-def main() -> None:
- args = parse_args()
- set_seed(args.seed)
+def _run_one_seed(
+ args,
+ sample_dataset,
+ test_ds,
+ test_loader,
+ device: str,
+ epochs: int,
+ run_seed: int,
+ alphas: list,
+ run_idx: int = 0,
+) -> dict:
+ """Train model + calibrate ClusterLabel for one seed across all alphas.
+
+ Training, embedding extraction, and base inference are done once; calibration
+ loops over alphas (fast — only threshold recomputed per alpha).
+
+ Returns {alpha: metrics_dict} where metrics_dict has keys:
+ accuracy, f1_weighted, coverage, miscoverage, avg_set_size
+ """
+ set_seed(run_seed)
+
+ train_ds, val_ds, cal_ds, _ = _do_split(
+ sample_dataset, args.ratios, run_seed, args.split_type
+ )
+ print(f" Split — Train: {len(train_ds)}, Val: {len(val_ds)}, "
+ f"Cal: {len(cal_ds)}, Test: {len(test_ds)} (fixed)")
- orig_stdout, orig_stderr = sys.stdout, sys.stderr
- log_file = None
- if args.log_file:
- log_file = open(args.log_file, "w", encoding="utf-8")
- sys.stdout = _Tee(orig_stdout, log_file)
- sys.stderr = _Tee(orig_stderr, log_file)
+ train_loader = get_dataloader(train_ds, batch_size=args.batch_size, shuffle=True)
+ val_loader = (
+ get_dataloader(val_ds, batch_size=args.batch_size, shuffle=False)
+ if len(val_ds) else None
+ )
- try:
- _run(args)
- finally:
- if log_file is not None:
- sys.stdout = orig_stdout
- sys.stderr = orig_stderr
- log_file.close()
+ if args.model == "tfm":
+ model = TFMTokenizer(dataset=sample_dataset).to(device)
+ _load_tfm_weights(model, args, run_idx)
+ else:
+ model = ContraWR(dataset=sample_dataset, n_fft=args.n_fft).to(device)
+ print(" Training ContraWR...")
+ trainer_tmp = Trainer(model=model, device=device, enable_logging=False)
+ trainer_tmp.train(
+ train_dataloader=train_loader,
+ val_dataloader=val_loader,
+ epochs=epochs,
+ monitor="accuracy" if val_loader is not None else None,
+ )
+ trainer = Trainer(model=model, device=device, enable_logging=False)
+ # Base model metrics — computed once, shared across all alphas
+ y_true_base, y_prob_base, _ = trainer.inference(test_loader)
+ base_metrics = get_metrics_fn("multiclass")(
+ y_true_base, y_prob_base, metrics=["accuracy", "f1_weighted"]
+ )
-def _run(args: argparse.Namespace) -> None:
+ # Extract embeddings once — reused for every alpha
+ print(" Extracting embeddings for train and calibration splits...")
+ train_embeddings = extract_embeddings(model, train_ds, batch_size=args.batch_size, device=device)
+ cal_embeddings = extract_embeddings(model, cal_ds, batch_size=args.batch_size, device=device)
+
+ # Calibration + evaluation — fast; loop over every alpha
+ results = {}
+ for alpha in alphas:
+ print(f" Calibrating ClusterLabel predictor (alpha={alpha})...")
+ cluster_predictor = ClusterLabel(
+ model=model,
+ alpha=float(alpha),
+ n_clusters=args.n_clusters,
+ random_state=run_seed,
+ )
+ cluster_predictor.calibrate(
+ cal_dataset=cal_ds,
+ train_embeddings=train_embeddings,
+ cal_embeddings=cal_embeddings,
+ )
+
+ y_true, y_prob, _, extra = Trainer(model=cluster_predictor).inference(
+ test_loader, additional_outputs=["y_predset"]
+ )
+ conf_metrics = get_metrics_fn("multiclass")(
+ y_true, y_prob,
+ metrics=["accuracy", "miscoverage_ps"],
+ y_predset=extra["y_predset"],
+ )
+
+ predset = extra["y_predset"]
+ predset_t = torch.tensor(predset) if isinstance(predset, np.ndarray) else predset
+ avg_set_size = predset_t.float().sum(dim=1).mean().item()
+
+ miscoverage = conf_metrics["miscoverage_ps"]
+ if isinstance(miscoverage, np.ndarray):
+ miscoverage = float(miscoverage.item() if miscoverage.size == 1 else miscoverage.mean())
+ else:
+ miscoverage = float(miscoverage)
+
+ results[alpha] = {
+ "accuracy": float(base_metrics["accuracy"]),
+ "f1_weighted": float(base_metrics["f1_weighted"]),
+ "coverage": 1.0 - miscoverage,
+ "miscoverage": miscoverage,
+ "avg_set_size": avg_set_size,
+ }
+ return results
+
+
+def _print_single_run_results(metrics: dict, alpha: float, n_clusters: int) -> None:
+ print("\nClusterLabel Results:")
+ print(f" Accuracy: {metrics['accuracy']:.4f}")
+ print(f" F1 (weighted): {metrics['f1_weighted']:.4f}")
+ print(f" Empirical coverage: {metrics['coverage']:.4f}")
+ print(f" Empirical miscoverage: {metrics['miscoverage']:.4f}")
+ print(f" Average set size: {metrics['avg_set_size']:.2f}")
+ print(f" Target coverage: {1 - alpha:.0%} (alpha={alpha})")
+ print(f" Number of clusters: {n_clusters}")
+
+
+def _print_multi_seed_summary(
+ all_metrics: list, run_seeds: list, alpha: float, n_test: int, n_clusters: int
+) -> None:
+ accs = np.array([m["accuracy"] for m in all_metrics])
+ f1s = np.array([m["f1_weighted"] for m in all_metrics])
+ coverages = np.array([m["coverage"] for m in all_metrics])
+ miscovs = np.array([m["miscoverage"] for m in all_metrics])
+ set_sizes = np.array([m["avg_set_size"] for m in all_metrics])
+ n_runs = len(all_metrics)
+
+ print("\n" + "=" * 80)
+ print(f"Per-run results — alpha={alpha} (ClusterLabel, fixed test set = TUH eval partition)")
+ print("=" * 80)
+ print(f" {'Run':<4} {'Seed':<6} {'Accuracy':<10} {'F1-Wt':<10} "
+ f"{'Coverage':<10} {'Miscoverage':<12} {'Avg set size':<12}")
+ print(" " + "-" * 68)
+ for i in range(n_runs):
+ m = all_metrics[i]
+ print(f" {i+1:<4} {run_seeds[i]:<6} {m['accuracy']:<10.4f} "
+ f"{m['f1_weighted']:<10.4f} {m['coverage']:<10.4f} "
+ f"{m['miscoverage']:<12.4f} {m['avg_set_size']:<12.2f}")
+
+ print("\n" + "=" * 80)
+ print(f"Summary — alpha={alpha} (mean \u00b1 std over {n_runs} runs, fixed test set)")
+ print(" Method: ClusterLabel")
+ print("=" * 80)
+ print(f" Accuracy: {accs.mean():.4f} \u00b1 {accs.std():.4f}")
+ print(f" F1 (weighted): {f1s.mean():.4f} \u00b1 {f1s.std():.4f}")
+ print(f" Empirical coverage: {coverages.mean():.4f} \u00b1 {coverages.std():.4f}")
+ print(f" Empirical miscoverage: {miscovs.mean():.4f} \u00b1 {miscovs.std():.4f}")
+ print(f" Average set size: {set_sizes.mean():.2f} \u00b1 {set_sizes.std():.2f}")
+ print(f" Target coverage: {1 - alpha:.0%} (alpha={alpha})")
+ print(f" Number of clusters: {n_clusters}")
+ print(f" Test set size: {n_test} (fixed across runs)")
+ print(f" Run seeds: {run_seeds}")
+ print("\n--- Min / Max (across runs) ---")
+ print(f" Coverage: [{coverages.min():.4f}, {coverages.max():.4f}]")
+ print(f" Set size: [{set_sizes.min():.2f}, {set_sizes.max():.2f}]")
+ print(f" Accuracy: [{accs.min():.4f}, {accs.max():.4f}]")
+
+
+def _main(args: argparse.Namespace) -> None:
device = args.device or ("cuda:0" if torch.cuda.is_available() else "cpu")
root = Path(args.root)
if not root.exists():
@@ -141,118 +345,107 @@ def _run(args: argparse.Namespace) -> None:
)
epochs = 2 if args.quick_test else args.epochs
- quick_test_max_samples = 2000 # cap samples so quick-test finishes in ~5-10 min
+ quick_test_max_samples = 2000
if args.quick_test:
print("*** QUICK TEST MODE (dev=True, 2 epochs, max 2000 samples) ***")
+ # -------------------------------------------------------------------------
+ # STEP 1: Load dataset ONCE — shared across all seeds
+ # -------------------------------------------------------------------------
print("=" * 80)
- print("STEP 1: Load TUEV + build task dataset")
+ print("STEP 1: Load TUEV + build task dataset (shared across all seeds)")
print("=" * 80)
dataset = TUEVDataset(root=str(root), subset=args.subset, dev=args.quick_test)
- sample_dataset = dataset.set_task(EEGEventsTUEV())
+ sample_dataset = dataset.set_task(EEGEventsTUEV(normalization="95th_percentile"), num_workers=16)
if args.quick_test and len(sample_dataset) > quick_test_max_samples:
sample_dataset = sample_dataset.subset(range(quick_test_max_samples))
print(f"Capped to {quick_test_max_samples} samples for quick-test.")
-
- print(f"Task samples: {len(sample_dataset)}")
- print(f"Input schema: {sample_dataset.input_schema}")
+ print(f"Task samples: {len(sample_dataset)}")
+ print(f"Input schema: {sample_dataset.input_schema}")
print(f"Output schema: {sample_dataset.output_schema}")
-
if len(sample_dataset) == 0:
raise RuntimeError("No samples produced. Verify TUEV root/subset/task.")
+ # -------------------------------------------------------------------------
+ # STEP 2: Extract the fixed test set ONCE
+ # -------------------------------------------------------------------------
print("\n" + "=" * 80)
- print("STEP 2: Split train/val/cal/test")
+ print("STEP 2: Extract fixed test set (TUH eval partition — same for all seeds)")
print("=" * 80)
- train_ds, val_ds, cal_ds, test_ds = split_by_sample_conformal(
- dataset=sample_dataset, ratios=list(args.ratios), seed=args.seed
+ _, _, _, test_ds = _do_split(
+ sample_dataset, args.ratios, args.seed, args.split_type
)
- print(f"Train: {len(train_ds)}")
- print(f"Val: {len(val_ds)}")
- print(f"Cal: {len(cal_ds)}")
- print(f"Test: {len(test_ds)}")
-
- train_loader = get_dataloader(train_ds, batch_size=args.batch_size, shuffle=True)
- val_loader = get_dataloader(val_ds, batch_size=args.batch_size, shuffle=False) if len(val_ds) else None
+ if len(test_ds) == 0 and args.quick_test:
+ print(" [quick-test] TUH eval partition empty in dev mode — using random 20% as test set.")
+ _, _, _, test_ds = split_by_sample_conformal(
+ dataset=sample_dataset, ratios=[0.6, 0.1, 0.1, 0.2], seed=args.seed
+ )
test_loader = get_dataloader(test_ds, batch_size=args.batch_size, shuffle=False)
+ print(f"Test: {len(test_ds)} (fixed)")
- print("\n" + "=" * 80)
- print("STEP 3: Train ContraWR")
- print("=" * 80)
- model = ContraWR(dataset=sample_dataset, n_fft=args.n_fft).to(device)
- trainer = Trainer(model=model, device=device, enable_logging=False)
-
- trainer.train(
- train_dataloader=train_loader,
- val_dataloader=val_loader,
- epochs=epochs,
- monitor="accuracy" if val_loader is not None else None,
- )
-
- print("\nBase model performance on test set:")
- y_true_base, y_prob_base, _loss_base = trainer.inference(test_loader)
- base_metrics = get_metrics_fn("multiclass")(y_true_base, y_prob_base, metrics=["accuracy", "f1_weighted"])
- for metric, value in base_metrics.items():
- print(f" {metric}: {value:.4f}")
-
- print("\n" + "=" * 80)
- print("STEP 4: K-means Cluster-Based Conformal Prediction (ClusterLabel)")
- print("=" * 80)
- print(f"Target miscoverage alpha: {args.alpha} (target coverage {1 - args.alpha:.0%})")
- print(f"Number of clusters: {args.n_clusters}")
-
- print("Extracting embeddings for training split...")
- train_embeddings = extract_embeddings(model, train_ds, batch_size=args.batch_size, device=device)
- print(f" train_embeddings shape: {train_embeddings.shape}")
-
- print("Extracting embeddings for calibration split...")
- cal_embeddings = extract_embeddings(model, cal_ds, batch_size=args.batch_size, device=device)
- print(f" cal_embeddings shape: {cal_embeddings.shape}")
+ # -------------------------------------------------------------------------
+ # Determine run seeds
+ # -------------------------------------------------------------------------
+ if args.seeds is not None:
+ run_seeds = [int(s.strip()) for s in args.seeds.split(",")]
+ else:
+ run_seeds = [args.seed + i for i in range(args.n_seeds)]
+
+ alphas = [float(a.strip()) for a in args.alphas.split(",")] if args.alphas else [args.alpha]
+
+ use_multi_seed = len(run_seeds) > 1
+ print(f"\nRun config: {'multi-seed (' + str(len(run_seeds)) + ' runs)' if use_multi_seed else 'single run'}")
+ print(f"Seeds: {run_seeds}, alphas={alphas}, n_clusters={args.n_clusters}")
+
+ # -------------------------------------------------------------------------
+ # STEP 3+: Train once per seed; calibrate for every alpha (fast)
+ # -------------------------------------------------------------------------
+ all_metrics = {alpha: [] for alpha in alphas}
+ for run_i, run_seed in enumerate(run_seeds):
+ print("\n" + "=" * 80)
+ if use_multi_seed:
+ print(f"Run {run_i + 1} / {len(run_seeds)} (seed={run_seed})")
+ else:
+ print(f"STEP 3–4: Train + Conformal Calibration (seed={run_seed})")
+ print("=" * 80)
+
+ seed_results = _run_one_seed(
+ args, sample_dataset, test_ds, test_loader, device, epochs, run_seed, alphas,
+ run_idx=run_i,
+ )
+ for alpha in alphas:
+ all_metrics[alpha].append(seed_results[alpha])
- cluster_predictor = ClusterLabel(
- model=model,
- alpha=float(args.alpha),
- n_clusters=args.n_clusters,
- random_state=args.seed,
- )
- print("Calibrating ClusterLabel predictor (fits K-means and computes cluster-specific thresholds)...")
- cluster_predictor.calibrate(
- cal_dataset=cal_ds,
- train_embeddings=train_embeddings,
- cal_embeddings=cal_embeddings,
- )
+ if use_multi_seed:
+ m = seed_results[alphas[0]]
+ print(f" [Run {run_i + 1} result (alpha={alphas[0]})] "
+ f"acc={m['accuracy']:.4f}, f1={m['f1_weighted']:.4f}, "
+ f"cov={m['coverage']:.4f}, set_size={m['avg_set_size']:.2f}")
- print("Evaluating ClusterLabel predictor on test set...")
- y_true, y_prob, _loss, extra = Trainer(model=cluster_predictor).inference(
- test_loader, additional_outputs=["y_predset"]
- )
+ for alpha in alphas:
+ if not use_multi_seed:
+ _print_single_run_results(all_metrics[alpha][0], alpha, args.n_clusters)
+ else:
+ _print_multi_seed_summary(all_metrics[alpha], run_seeds, alpha, len(test_ds), args.n_clusters)
- cluster_metrics = get_metrics_fn("multiclass")(
- y_true,
- y_prob,
- metrics=["accuracy", "miscoverage_ps"],
- y_predset=extra["y_predset"],
- )
- predset = extra["y_predset"]
- if isinstance(predset, np.ndarray):
- predset_t = torch.tensor(predset)
- else:
- predset_t = predset
- avg_set_size = predset_t.float().sum(dim=1).mean().item()
+def main() -> None:
+ args = parse_args()
- miscoverage = cluster_metrics["miscoverage_ps"]
- if isinstance(miscoverage, np.ndarray):
- miscoverage = float(miscoverage.item() if miscoverage.size == 1 else miscoverage.mean())
- else:
- miscoverage = float(miscoverage)
+ orig_stdout, orig_stderr = sys.stdout, sys.stderr
+ log_file = None
+ if args.log_file:
+ log_file = open(args.log_file, "w", encoding="utf-8")
+ sys.stdout = _Tee(orig_stdout, log_file)
+ sys.stderr = _Tee(orig_stderr, log_file)
- print("\nClusterLabel Results:")
- print(f" Accuracy: {cluster_metrics['accuracy']:.4f}")
- print(f" Empirical miscoverage: {miscoverage:.4f}")
- print(f" Empirical coverage: {1 - miscoverage:.4f}")
- print(f" Average set size: {avg_set_size:.2f}")
- print(f" Number of clusters: {args.n_clusters}")
+ try:
+ _main(args)
+ finally:
+ if log_file is not None:
+ sys.stdout = orig_stdout
+ sys.stderr = orig_stderr
+ log_file.close()
if __name__ == "__main__":
diff --git a/examples/conformal_eeg/tuev_ncp_conformal.py b/examples/conformal_eeg/tuev_ncp_conformal.py
index c5e207a6a..77cc98475 100644
--- a/examples/conformal_eeg/tuev_ncp_conformal.py
+++ b/examples/conformal_eeg/tuev_ncp_conformal.py
@@ -20,6 +20,7 @@
from __future__ import annotations
import argparse
+import os
import random
import sys
from pathlib import Path
@@ -47,8 +48,8 @@ def flush(self):
from pyhealth.calib.predictionset.cluster import NeighborhoodLabel
from pyhealth.calib.utils import extract_embeddings
-from pyhealth.datasets import TUEVDataset, get_dataloader, split_by_sample_conformal
-from pyhealth.models import ContraWR
+from pyhealth.datasets import TUEVDataset, get_dataloader, split_by_patient_conformal_tuh, split_by_sample_conformal_tuh, split_by_sample_conformal
+from pyhealth.models import ContraWR, TFMTokenizer
from pyhealth.tasks import EEGEventsTUEV
from pyhealth.trainer import Trainer, get_metrics_fn
@@ -86,13 +87,17 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--batch-size", type=int, default=64)
parser.add_argument("--epochs", type=int, default=20)
parser.add_argument("--alpha", type=float, default=0.1, help="Miscoverage rate (e.g., 0.1 => 90% target coverage).")
+ parser.add_argument(
+ "--alphas", type=str, default=None,
+ help="Comma-separated miscoverage rates, e.g. '0.2,0.1,0.05,0.01'. Overrides --alpha.",
+ )
parser.add_argument(
"--ratios",
type=float,
- nargs=4,
- default=(0.6, 0.1, 0.15, 0.15),
- metavar=("TRAIN", "VAL", "CAL", "TEST"),
- help="Split ratios for train/val/cal/test. Must sum to 1.0.",
+ nargs=3,
+ default=(0.6, 0.2, 0.2),
+ metavar=("TRAIN", "VAL", "CAL"),
+ help="Ratios for splitting the TUH train partition into train/val/cal. Must sum to 1.0. Test is fixed as the TUH eval partition.",
)
parser.add_argument(
"--k-neighbors",
@@ -107,6 +112,10 @@ def parse_args() -> argparse.Namespace:
help="Temperature for NCP exponential weights; smaller => more localization.",
)
parser.add_argument("--n-fft", type=int, default=128, help="STFT FFT size used by ContraWR.")
+ parser.add_argument(
+ "--model", type=str, default="contrawr", choices=["contrawr", "tfm"],
+ help="Backbone model: 'contrawr' (default) or 'tfm' (TFMTokenizer).",
+ )
parser.add_argument(
"--device",
type=str,
@@ -124,9 +133,48 @@ def parse_args() -> argparse.Namespace:
action="store_true",
help="Smoke test: dev=True, max 2000 samples, 2 epochs, ~5-10 min.",
)
+ parser.add_argument(
+ "--weights-dir",
+ type=str,
+ default="weightfiles/TFM_Tokenizer_multiple_finetuned_on_TUEV",
+ help="Root folder of fine-tuned TFM classifier checkpoints (only with --model tfm).",
+ )
+ parser.add_argument(
+ "--tokenizer-weights",
+ type=str,
+ default="weightfiles/tfm_tokenizer_last.pth",
+ help="Path to the pre-trained TFM tokenizer weights (only with --model tfm).",
+ )
+ parser.add_argument(
+ "--split-type",
+ type=str,
+ default="patient",
+ choices=["patient", "sample"],
+ help="Split strategy: 'patient' (default, patient-level, no leakage) or "
+ "'sample' (original sample-level, for comparison).",
+ )
return parser.parse_args()
+def _do_split(dataset, ratios, seed, split_type):
+ """Dispatch to the correct TUH split function based on split_type."""
+ if split_type == "patient":
+ return split_by_patient_conformal_tuh(dataset=dataset, ratios=list(ratios), seed=seed)
+ else:
+ return split_by_sample_conformal_tuh(dataset=dataset, ratios=list(ratios), seed=seed)
+
+
+def _load_tfm_weights(model, args, run_idx: int) -> None:
+ """Load pre-trained tokenizer + fine-tuned classifier for run_idx (0-based)."""
+ base = os.path.basename(args.weights_dir)
+ classifier_path = os.path.join(args.weights_dir, f"{base}_{run_idx + 1}", "best_model.pth")
+ print(f" Loading TFM weights (run {run_idx + 1}): {classifier_path}")
+ model.load_pretrained_weights(
+ tokenizer_checkpoint_path=args.tokenizer_weights,
+ classifier_checkpoint_path=classifier_path,
+ )
+
+
def set_seed(seed: int) -> None:
random.seed(seed)
np.random.seed(seed)
@@ -135,27 +183,13 @@ def set_seed(seed: int) -> None:
torch.cuda.manual_seed_all(seed)
-def _split_remainder_into_train_val_cal(sample_dataset, remainder_indices, ratios, run_seed):
- """Split remainder indices into train/val/cal by renormalized ratios. Uses run_seed for shuffle."""
- r0, r1, r2, r3 = ratios
- remainder_frac = 1.0 - r3
- if remainder_frac <= 0:
- raise ValueError("Test ratio must be < 1 so remainder (train+val+cal) is non-empty.")
- # Renormalize so train/val/cal ratios sum to 1 on the remainder
- r_train = r0 / remainder_frac
- r_val = r1 / remainder_frac
- remainder = np.asarray(remainder_indices, dtype=np.int64)
- np.random.seed(run_seed)
- shuffled = np.random.permutation(remainder)
- M = len(shuffled)
- train_end = int(M * r_train)
- val_end = int(M * (r_train + r_val))
- train_index = shuffled[:train_end]
- val_index = shuffled[train_end:val_end]
- cal_index = shuffled[val_end:]
- train_ds = sample_dataset.subset(train_index.tolist())
- val_ds = sample_dataset.subset(val_index.tolist())
- cal_ds = sample_dataset.subset(cal_index.tolist())
+def _split_train_pool_for_run(sample_dataset, ratios, run_seed, split_type="patient"):
+ """Re-split the TUH train partition into train/val/cal for one run seed.
+
+ The test set (TUH eval partition) is always fixed regardless of seed, so
+ only train/val/cal change across runs in multi-seed mode.
+ """
+ train_ds, val_ds, cal_ds, _ = _do_split(sample_dataset, ratios, run_seed, split_type)
return train_ds, val_ds, cal_ds
@@ -168,86 +202,81 @@ def _run_one_ncp(
args,
device,
epochs,
- return_metrics=False,
+ alphas: list,
+ run_idx: int = 0,
):
- """Train ContraWR, calibrate NCP, evaluate on test. Optionally return metrics dict for aggregation."""
+ """Train model + calibrate NCP for one seed across all alphas.
+
+ Training, embedding extraction, and base inference are done once; calibration
+ loops over alphas (fast — only threshold recomputed per alpha).
+
+ Returns {alpha: metrics_dict} where metrics_dict has keys:
+ accuracy, f1_weighted, coverage, miscoverage, avg_set_size
+ """
train_loader = get_dataloader(train_ds, batch_size=args.batch_size, shuffle=True)
val_loader = get_dataloader(val_ds, batch_size=args.batch_size, shuffle=False) if len(val_ds) else None
- print("\n" + "=" * 80)
- print("STEP 3: Train ContraWR")
- print("=" * 80)
- model = ContraWR(dataset=sample_dataset, n_fft=args.n_fft).to(device)
- trainer = Trainer(model=model, device=device, enable_logging=False)
- trainer.train(
- train_dataloader=train_loader,
- val_dataloader=val_loader,
- epochs=epochs,
- monitor="accuracy" if val_loader is not None else None,
- )
-
- if not return_metrics:
- print("\nBase model performance on test set:")
- y_true_base, y_prob_base, _loss_base = trainer.inference(test_loader)
- base_metrics = get_metrics_fn("multiclass")(
- y_true_base, y_prob_base, metrics=["accuracy", "f1_weighted"]
+ if args.model == "tfm":
+ model = TFMTokenizer(dataset=sample_dataset).to(device)
+ _load_tfm_weights(model, args, run_idx)
+ else:
+ model = ContraWR(dataset=sample_dataset, n_fft=args.n_fft).to(device)
+ print(" Training ContraWR...")
+ trainer_tmp = Trainer(model=model, device=device, enable_logging=False)
+ trainer_tmp.train(
+ train_dataloader=train_loader,
+ val_dataloader=val_loader,
+ epochs=epochs,
+ monitor="accuracy" if val_loader is not None else None,
)
- for metric, value in base_metrics.items():
- print(f" {metric}: {value:.4f}")
+ trainer = Trainer(model=model, device=device, enable_logging=False)
- print("\n" + "=" * 80)
- print("STEP 4: Neighborhood Conformal Prediction (NCP / NeighborhoodLabel)")
- print("=" * 80)
- print(f"Target miscoverage alpha: {args.alpha} (target coverage {1 - args.alpha:.0%})")
- print(f"k_neighbors: {args.k_neighbors}, lambda_L: {args.lambda_L}")
+ # Base model metrics — computed once, shared across all alphas
+ y_true_base, y_prob_base, _ = trainer.inference(test_loader)
+ base_metrics = get_metrics_fn("multiclass")(
+ y_true_base, y_prob_base, metrics=["accuracy", "f1_weighted"]
+ )
+ # Extract calibration embeddings once — reused for every alpha
+ print(" Extracting calibration embeddings...")
cal_embeddings = extract_embeddings(model, cal_ds, batch_size=args.batch_size, device=device)
- if not return_metrics:
- print(f" cal_embeddings shape: {cal_embeddings.shape}")
-
- ncp_predictor = NeighborhoodLabel(
- model=model,
- alpha=float(args.alpha),
- k_neighbors=args.k_neighbors,
- lambda_L=args.lambda_L,
- )
- ncp_predictor.calibrate(cal_dataset=cal_ds, cal_embeddings=cal_embeddings)
- y_true, y_prob, _loss, extra = Trainer(model=ncp_predictor).inference(
- test_loader, additional_outputs=["y_predset"]
- )
- ncp_metrics = get_metrics_fn("multiclass")(
- y_true, y_prob, metrics=["accuracy", "miscoverage_ps"], y_predset=extra["y_predset"]
- )
- predset = extra["y_predset"]
- if isinstance(predset, np.ndarray):
- predset_t = torch.tensor(predset)
- else:
- predset_t = predset
- avg_set_size = predset_t.float().sum(dim=1).mean().item()
- miscoverage = ncp_metrics["miscoverage_ps"]
- if isinstance(miscoverage, np.ndarray):
- miscoverage = float(miscoverage.item() if miscoverage.size == 1 else miscoverage.mean())
- else:
- miscoverage = float(miscoverage)
- coverage = 1.0 - miscoverage
+ # Calibration + evaluation — fast; loop over every alpha
+ results = {}
+ for alpha in alphas:
+ print(f" Calibrating NCP predictor (alpha={alpha})...")
+ ncp_predictor = NeighborhoodLabel(
+ model=model,
+ alpha=float(alpha),
+ k_neighbors=args.k_neighbors,
+ lambda_L=args.lambda_L,
+ )
+ ncp_predictor.calibrate(cal_dataset=cal_ds, cal_embeddings=cal_embeddings)
- if return_metrics:
- return {
- "accuracy": float(ncp_metrics["accuracy"]),
- "coverage": coverage,
+ y_true, y_prob, _, extra = Trainer(model=ncp_predictor).inference(
+ test_loader, additional_outputs=["y_predset"]
+ )
+ ncp_metrics = get_metrics_fn("multiclass")(
+ y_true, y_prob, metrics=["accuracy", "miscoverage_ps"], y_predset=extra["y_predset"]
+ )
+ predset = extra["y_predset"]
+ predset_t = torch.tensor(predset) if isinstance(predset, np.ndarray) else predset
+ avg_set_size = predset_t.float().sum(dim=1).mean().item()
+
+ miscoverage = ncp_metrics["miscoverage_ps"]
+ if isinstance(miscoverage, np.ndarray):
+ miscoverage = float(miscoverage.item() if miscoverage.size == 1 else miscoverage.mean())
+ else:
+ miscoverage = float(miscoverage)
+
+ results[alpha] = {
+ "accuracy": float(base_metrics["accuracy"]),
+ "f1_weighted": float(base_metrics["f1_weighted"]),
+ "coverage": 1.0 - miscoverage,
"miscoverage": miscoverage,
"avg_set_size": avg_set_size,
}
-
- print("\nNCP (NeighborhoodLabel) Results:")
- print(f" Accuracy: {ncp_metrics['accuracy']:.4f}")
- print(f" Empirical miscoverage: {miscoverage:.4f}")
- print(f" Empirical coverage: {coverage:.4f}")
- print(f" Average set size: {avg_set_size:.2f}")
- print(f" k_neighbors: {args.k_neighbors}")
- print("\n--- Single-run summary (for reporting) ---")
- print(f" alpha={args.alpha}, target_coverage={1 - args.alpha:.2f}, empirical_coverage={coverage:.4f}, miscoverage={miscoverage:.4f}, accuracy={ncp_metrics['accuracy']:.4f}, avg_set_size={avg_set_size:.2f}")
+ return results
def main() -> None:
@@ -290,7 +319,7 @@ def _run(args: argparse.Namespace) -> None:
print("STEP 1: Load TUEV + build task dataset")
print("=" * 80)
dataset = TUEVDataset(root=str(root), subset=args.subset, dev=args.quick_test)
- sample_dataset = dataset.set_task(EEGEventsTUEV())
+ sample_dataset = dataset.set_task(EEGEventsTUEV(normalization="95th_percentile"), num_workers=16)
if args.quick_test and len(sample_dataset) > quick_test_max_samples:
sample_dataset = sample_dataset.subset(range(quick_test_max_samples))
print(f"Capped to {quick_test_max_samples} samples for quick-test.")
@@ -299,87 +328,58 @@ def _run(args: argparse.Namespace) -> None:
print(f"Input schema: {sample_dataset.input_schema}")
print(f"Output schema: {sample_dataset.output_schema}")
- # Experiment configuration (for PI / reporting)
- print("\n--- Experiment configuration ---")
- print(f" dataset_root: {root}")
- print(f" subset: {args.subset}, ratios: train/val/cal/test = {args.ratios[0]:.2f}/{args.ratios[1]:.2f}/{args.ratios[2]:.2f}/{args.ratios[3]:.2f}")
- print(f" alpha: {args.alpha} (target coverage {1 - args.alpha:.0%})")
- print(f" k_neighbors: {args.k_neighbors}, lambda_L: {args.lambda_L}")
- print(f" epochs: {epochs}, batch_size: {args.batch_size}, device: {device}, seed: {args.seed}")
-
if len(sample_dataset) == 0:
raise RuntimeError("No samples produced. Verify TUEV root/subset/task.")
+ # Parse alphas and run seeds
+ alphas = [float(a.strip()) for a in args.alphas.split(",")] if args.alphas else [args.alpha]
ratios = list(args.ratios)
use_multi_seed = args.n_seeds > 1 or args.seeds is not None
- if use_multi_seed:
- run_seeds = (
- [int(s.strip()) for s in args.seeds.split(",")]
- if args.seeds
- else [args.seed + i for i in range(args.n_seeds)]
- )
- n_runs = len(run_seeds)
- print(f" multi_seed: n_runs={n_runs}, run_seeds={run_seeds}, split_seed={args.split_seed} (fixed test set)")
- print(f"Multi-seed mode: {n_runs} runs (fixed test set), run seeds: {run_seeds}")
-
- if not use_multi_seed:
- # Single run: original behavior
- print("\n" + "=" * 80)
- print("STEP 2: Split train/val/cal/test")
- print("=" * 80)
- train_ds, val_ds, cal_ds, test_ds = split_by_sample_conformal(
- dataset=sample_dataset, ratios=ratios, seed=args.seed
- )
- print(f"Train: {len(train_ds)}")
- print(f"Val: {len(val_ds)}")
- print(f"Cal: {len(cal_ds)}")
- print(f"Test: {len(test_ds)}")
-
- test_loader = get_dataloader(test_ds, batch_size=args.batch_size, shuffle=False)
- _run_one_ncp(
- sample_dataset=sample_dataset,
- train_ds=train_ds,
- val_ds=val_ds,
- cal_ds=cal_ds,
- test_loader=test_loader,
- args=args,
- device=device,
- epochs=epochs,
- )
- print("\n--- Split sizes and seed (for reporting) ---")
- print(f" train={len(train_ds)}, val={len(val_ds)}, cal={len(cal_ds)}, test={len(test_ds)}, seed={args.seed}")
- return
+ run_seeds = (
+ [int(s.strip()) for s in args.seeds.split(",")]
+ if args.seeds
+ else [args.seed + i for i in range(args.n_seeds)]
+ )
+ n_runs = len(run_seeds)
- # Multi-seed: fix test set, vary train/val/cal per run
+ # -------------------------------------------------------------------------
+ # STEP 2: Extract the fixed test set ONCE
+ # -------------------------------------------------------------------------
print("\n" + "=" * 80)
- print("STEP 2: Fix test set (split-seed), then run multiple train/cal splits")
+ print("STEP 2: Extract fixed test set (TUH eval partition — same for all seeds)")
print("=" * 80)
- train_idx, val_idx, cal_idx, test_idx = split_by_sample_conformal(
- dataset=sample_dataset, ratios=ratios, seed=args.split_seed, get_index=True
+ _, _, _, test_ds = _do_split(
+ sample_dataset, ratios, args.split_seed, args.split_type
)
- # Convert to numpy for indexing
- train_index = train_idx.numpy() if hasattr(train_idx, "numpy") else np.array(train_idx)
- val_index = val_idx.numpy() if hasattr(val_idx, "numpy") else np.array(val_idx)
- cal_index = cal_idx.numpy() if hasattr(cal_idx, "numpy") else np.array(cal_idx)
- test_index = test_idx.numpy() if hasattr(test_idx, "numpy") else np.array(test_idx)
- remainder_indices = np.concatenate([train_index, val_index, cal_index])
- test_ds = sample_dataset.subset(test_index.tolist())
+ if len(test_ds) == 0 and args.quick_test:
+ print(" [quick-test] TUH eval partition empty in dev mode — using random 20% as test set.")
+ _, _, _, test_ds = split_by_sample_conformal(
+ dataset=sample_dataset, ratios=[0.6, 0.1, 0.1, 0.2], seed=args.split_seed
+ )
test_loader = get_dataloader(test_ds, batch_size=args.batch_size, shuffle=False)
n_test = len(test_ds)
- print(f"Fixed test set size: {n_test}")
+ print(f"Test: {n_test} (fixed)")
+
+ print(f"\nRun config: {'multi-seed (' + str(n_runs) + ' runs)' if use_multi_seed else 'single run'}")
+ print(f"Seeds: {run_seeds}, alphas={alphas}, k_neighbors={args.k_neighbors}")
- accs, coverages, miscoverages, set_sizes = [], [], [], []
+ # -------------------------------------------------------------------------
+ # STEP 3+: Train once per seed; calibrate for every alpha (fast)
+ # -------------------------------------------------------------------------
+ all_metrics = {alpha: [] for alpha in alphas}
for run_i, run_seed in enumerate(run_seeds):
print("\n" + "=" * 80)
- print(f"Run {run_i + 1} / {n_runs} (seed={run_seed})")
+ if use_multi_seed:
+ print(f"Run {run_i + 1} / {n_runs} (seed={run_seed})")
+ else:
+ print(f"STEP 3–4: Train + NCP Calibration (seed={run_seed})")
print("=" * 80)
set_seed(run_seed)
- train_ds, val_ds, cal_ds = _split_remainder_into_train_val_cal(
- sample_dataset, remainder_indices, ratios, run_seed
- )
- print(f"Train: {len(train_ds)}, Val: {len(val_ds)}, Cal: {len(cal_ds)}")
+ train_ds, val_ds, cal_ds = _split_train_pool_for_run(sample_dataset, ratios, run_seed, args.split_type)
+ print(f" Split — Train: {len(train_ds)}, Val: {len(val_ds)}, "
+ f"Cal: {len(cal_ds)}, Test: {n_test} (fixed)")
- metrics = _run_one_ncp(
+ seed_results = _run_one_ncp(
sample_dataset=sample_dataset,
train_ds=train_ds,
val_ds=val_ds,
@@ -388,42 +388,66 @@ def _run(args: argparse.Namespace) -> None:
args=args,
device=device,
epochs=epochs,
- return_metrics=True,
+ alphas=alphas,
+ run_idx=run_i,
)
- accs.append(metrics["accuracy"])
- coverages.append(metrics["coverage"])
- miscoverages.append(metrics["miscoverage"])
- set_sizes.append(metrics["avg_set_size"])
-
- accs = np.array(accs)
- coverages = np.array(coverages)
- miscoverages_arr = np.array(miscoverages)
- set_sizes = np.array(set_sizes)
-
- # Per-run table (for PI / reporting)
- print("\n" + "=" * 80)
- print("Per-run NCP results (fixed test set)")
- print("=" * 80)
- print(f" {'Run':<4} {'Seed':<6} {'Accuracy':<10} {'Coverage':<10} {'Miscoverage':<12} {'Avg set size':<12}")
- print(" " + "-" * 54)
- for i in range(n_runs):
- print(f" {i+1:<4} {run_seeds[i]:<6} {accs[i]:<10.4f} {coverages[i]:<10.4f} {miscoverages_arr[i]:<12.4f} {set_sizes[i]:<12.2f}")
-
- print("\n" + "=" * 80)
- print("NCP summary (mean ± std over {} runs, fixed test set)".format(n_runs))
- print("=" * 80)
- print(f" Accuracy: {accs.mean():.4f} ± {accs.std():.4f}")
- print(f" Empirical coverage: {coverages.mean():.4f} ± {coverages.std():.4f}")
- print(f" Empirical miscoverage: {miscoverages_arr.mean():.4f} ± {miscoverages_arr.std():.4f}")
- print(f" Average set size: {set_sizes.mean():.2f} ± {set_sizes.std():.2f}")
- print(f" Target coverage: {1 - args.alpha:.0%} (alpha={args.alpha})")
- print(f" k_neighbors: {args.k_neighbors}, lambda_L: {args.lambda_L}")
- print(f" Test set size: {n_test} (fixed across runs)")
- print(f" Run seeds: {run_seeds}")
- print("\n--- Min / Max (across runs) ---")
- print(f" Coverage: [{coverages.min():.4f}, {coverages.max():.4f}]")
- print(f" Set size: [{set_sizes.min():.2f}, {set_sizes.max():.2f}]")
- print(f" Accuracy: [{accs.min():.4f}, {accs.max():.4f}]")
+ for alpha in alphas:
+ all_metrics[alpha].append(seed_results[alpha])
+
+ if use_multi_seed:
+ m = seed_results[alphas[0]]
+ print(f" [Run {run_i + 1} result (alpha={alphas[0]})] "
+ f"acc={m['accuracy']:.4f}, f1={m['f1_weighted']:.4f}, "
+ f"cov={m['coverage']:.4f}, set_size={m['avg_set_size']:.2f}")
+
+ for alpha in alphas:
+ mlist = all_metrics[alpha]
+ accs = np.array([m["accuracy"] for m in mlist])
+ f1s = np.array([m["f1_weighted"] for m in mlist])
+ coverages = np.array([m["coverage"] for m in mlist])
+ miscovs = np.array([m["miscoverage"] for m in mlist])
+ set_sizes = np.array([m["avg_set_size"] for m in mlist])
+
+ if not use_multi_seed:
+ print("\n" + "=" * 80)
+ print(f"Summary — alpha={alpha} (single run, fixed test set)")
+ print(" Method: NeighborhoodLabel")
+ print("=" * 80)
+ print(f" Accuracy: {accs[0]:.4f}")
+ print(f" F1 (weighted): {f1s[0]:.4f}")
+ print(f" Empirical coverage: {coverages[0]:.4f}")
+ print(f" Empirical miscoverage: {miscovs[0]:.4f}")
+ print(f" Average set size: {set_sizes[0]:.2f}")
+ print(f" Target coverage: {1 - alpha:.0%} (alpha={alpha})")
+ print(f" k_neighbors: {args.k_neighbors}, lambda_L: {args.lambda_L}")
+ else:
+ print("\n" + "=" * 80)
+ print(f"Per-run results — alpha={alpha} (NeighborhoodLabel, fixed test set = TUH eval partition)")
+ print("=" * 80)
+ print(f" {'Run':<4} {'Seed':<6} {'Accuracy':<10} {'F1-Wt':<10} "
+ f"{'Coverage':<10} {'Miscoverage':<12} {'Avg set size':<12}")
+ print(" " + "-" * 68)
+ for i in range(n_runs):
+ print(f" {i+1:<4} {run_seeds[i]:<6} {accs[i]:<10.4f} {f1s[i]:<10.4f} "
+ f"{coverages[i]:<10.4f} {miscovs[i]:<12.4f} {set_sizes[i]:<12.2f}")
+
+ print("\n" + "=" * 80)
+ print(f"Summary — alpha={alpha} (mean \u00b1 std over {n_runs} runs, fixed test set)")
+ print(" Method: NeighborhoodLabel")
+ print("=" * 80)
+ print(f" Accuracy: {accs.mean():.4f} \u00b1 {accs.std():.4f}")
+ print(f" F1 (weighted): {f1s.mean():.4f} \u00b1 {f1s.std():.4f}")
+ print(f" Empirical coverage: {coverages.mean():.4f} \u00b1 {coverages.std():.4f}")
+ print(f" Empirical miscoverage: {miscovs.mean():.4f} \u00b1 {miscovs.std():.4f}")
+ print(f" Average set size: {set_sizes.mean():.2f} \u00b1 {set_sizes.std():.2f}")
+ print(f" Target coverage: {1 - alpha:.0%} (alpha={alpha})")
+ print(f" k_neighbors: {args.k_neighbors}, lambda_L: {args.lambda_L}")
+ print(f" Test set size: {n_test} (fixed across runs)")
+ print(f" Run seeds: {run_seeds}")
+ print("\n--- Min / Max (across runs) ---")
+ print(f" Coverage: [{coverages.min():.4f}, {coverages.max():.4f}]")
+ print(f" Set size: [{set_sizes.min():.2f}, {set_sizes.max():.2f}]")
+ print(f" Accuracy: [{accs.min():.4f}, {accs.max():.4f}]")
if __name__ == "__main__":
diff --git a/examples/cxr/covid19cxr_tutorial.ipynb b/examples/cxr/covid19cxr_tutorial.ipynb
index 2a04844c5..ec10756a1 100644
--- a/examples/cxr/covid19cxr_tutorial.ipynb
+++ b/examples/cxr/covid19cxr_tutorial.ipynb
@@ -1339,7 +1339,7 @@
" # Input size is inferred automatically from image dimensions\n",
" result = chefer_gen.attribute(\n",
" interpolate=True,\n",
- " class_index=pred_class,\n",
+ " target_class_idx=pred_class,\n",
" **batch\n",
" )\n",
" attr_map = result[\"image\"] # Keyed by task schema's feature key\n",
diff --git a/examples/cxr/covid19cxr_tutorial.py b/examples/cxr/covid19cxr_tutorial.py
index 0f24f4b58..06b134f93 100644
--- a/examples/cxr/covid19cxr_tutorial.py
+++ b/examples/cxr/covid19cxr_tutorial.py
@@ -131,7 +131,7 @@
# Compute attribution for each class in the prediction set
overlays = []
for class_idx in predset_class_indices:
- attr_map = chefer.attribute(class_index=class_idx, **batch)["image"]
+ attr_map = chefer.attribute(target_class_idx=class_idx, **batch)["image"]
_, _, overlay = visualize_image_attr(
image=batch["image"][0],
attribution=attr_map[0, 0],
diff --git a/examples/cxr/covid19cxr_tutorial_display.py b/examples/cxr/covid19cxr_tutorial_display.py
index 3f6a33b82..f3a4acddb 100644
--- a/examples/cxr/covid19cxr_tutorial_display.py
+++ b/examples/cxr/covid19cxr_tutorial_display.py
@@ -128,7 +128,7 @@
# Compute attribution for each class in the prediction set
overlays = []
for class_idx in predset_class_indices:
- attr_map = chefer.attribute(class_index=class_idx, **batch)["image"]
+ attr_map = chefer.attribute(target_class_idx=class_idx, **batch)["image"]
_, _, overlay = visualize_image_attr(
image=batch["image"][0],
attribution=attr_map[0, 0],
diff --git a/examples/drug_recommendation/drug_recommendation_mimic4_adacare.py b/examples/drug_recommendation/drug_recommendation_mimic4_adacare.py
new file mode 100644
index 000000000..2fbad0e38
--- /dev/null
+++ b/examples/drug_recommendation/drug_recommendation_mimic4_adacare.py
@@ -0,0 +1,121 @@
+"""
+Example of using AdaCare for drug recommendation on MIMIC-IV.
+
+This example demonstrates:
+1. Loading MIMIC-IV data
+2. Applying the DrugRecommendationMIMIC4 task
+3. Creating a SampleDataset with nested sequence processors
+4. Training an AdaCare model
+"""
+
+import torch
+
+from pyhealth.datasets import (
+ MIMIC4Dataset,
+ get_dataloader,
+ split_by_patient,
+)
+from pyhealth.models import AdaCare
+from pyhealth.tasks import DrugRecommendationMIMIC4
+from pyhealth.trainer import Trainer
+
+if __name__ == "__main__":
+ # STEP 1: Load MIMIC-IV base dataset
+ base_dataset = MIMIC4Dataset(
+ ehr_root="/srv/local/data/physionet.org/files/mimiciv/2.2/",
+ cache_dir="/shared/eng/pyhealth_agent/baselines", # Change this to your desired cache directory
+ ehr_tables=[
+ "patients",
+ "admissions",
+ "diagnoses_icd",
+ "procedures_icd",
+ "prescriptions",
+ ],
+ )
+
+ # STEP 2: Apply drug recommendation task
+ sample_dataset = base_dataset.set_task(
+ DrugRecommendationMIMIC4(),
+ num_workers=4,
+ )
+
+ print(f"Total samples: {len(sample_dataset)}")
+ print(f"Input schema: {sample_dataset.input_schema}")
+ print(f"Output schema: {sample_dataset.output_schema}")
+
+ # Inspect a sample
+ sample = sample_dataset[0]
+ print("\nSample structure:")
+ print(f" Patient ID: {sample['patient_id']}")
+ print(f" Visit ID: {sample['visit_id']}")
+ print(f" Conditions (history): {len(sample['conditions'])} visits")
+ print(f" Procedures (history): {len(sample['procedures'])} visits")
+ print(f" Drugs history: {len(sample['drugs_hist'])} visits")
+ print(f" Target drugs: {len(sample['drugs'])} drugs")
+ print(f"\n First visit conditions: {sample['conditions'][0][:5]}...")
+ print(f" Target drugs sample: {sample['drugs'][:5]}...")
+
+ # STEP 3: Split dataset
+ train_dataset, val_dataset, test_dataset = split_by_patient(
+ sample_dataset, [0.8, 0.1, 0.1]
+ )
+
+ print("\nDataset split:")
+ print(f" Train: {len(train_dataset)} samples")
+ print(f" Validation: {len(val_dataset)} samples")
+ print(f" Test: {len(test_dataset)} samples")
+
+ # Create dataloaders
+ train_loader = get_dataloader(train_dataset, batch_size=64, shuffle=True)
+ val_loader = get_dataloader(val_dataset, batch_size=64, shuffle=False)
+ test_loader = get_dataloader(test_dataset, batch_size=64, shuffle=False)
+
+ # STEP 4: Initialize AdaCare model
+ model = AdaCare(
+ dataset=sample_dataset,
+ embedding_dim=128,
+ hidden_dim=128,
+ )
+
+ num_params = sum(p.numel() for p in model.parameters())
+ print(f"\nModel initialized with {num_params:,} parameters")
+ print(f"Feature keys: {model.feature_keys}")
+ print(f"Label key: {model.label_keys[0]}")
+
+ # STEP 5: Train the model
+ trainer = Trainer(
+ model=model,
+ device="cuda:0", # or "cpu"
+ metrics=["pr_auc_samples", "f1_samples", "jaccard_samples"],
+ )
+
+ print("\nStarting training...")
+ trainer.train(
+ train_dataloader=train_loader,
+ val_dataloader=val_loader,
+ epochs=50,
+ monitor="pr_auc_samples",
+ optimizer_params={"lr": 1e-4},
+ optimizer_class=torch.optim.AdamW,
+ )
+
+ # STEP 6: Evaluate on test set
+ print("\nEvaluating on test set...")
+ results = trainer.evaluate(test_loader)
+ print("\nTest Results:")
+ for metric, value in results.items():
+ print(f" {metric}: {value:.4f}")
+
+ # STEP 7: Inspect model predictions
+ print("\nSample predictions:")
+ sample_batch = next(iter(test_loader))
+
+ with torch.no_grad():
+ output = model(**sample_batch)
+
+ print(f" Batch size: {output['y_prob'].shape[0]}")
+ print(f" Number of drug classes: {output['y_prob'].shape[1]}")
+ print(" Predicted probabilities (first 5 drugs of first patient):")
+ print(f" {output['y_prob'][0, :5].cpu().numpy()}")
+ print(" True labels (first 5 drugs of first patient):")
+ print(f" {output['y_true'][0, :5].cpu().numpy()}")
diff --git a/examples/drug_recommendation/drug_recommendation_mimic4_adacare_optuna.py b/examples/drug_recommendation/drug_recommendation_mimic4_adacare_optuna.py
new file mode 100644
index 000000000..ee618bfb7
--- /dev/null
+++ b/examples/drug_recommendation/drug_recommendation_mimic4_adacare_optuna.py
@@ -0,0 +1,201 @@
+"""
+Optuna hyperparameter tuning for AdaCare on drug recommendation with MIMIC-IV.
+
+This example demonstrates:
+1. Loading MIMIC-IV data and applying the DrugRecommendationMIMIC4 task
+2. Defining an Optuna objective that tunes AdaCare-specific hyperparameters
+3. Running 10 Optuna trials to find the best configuration
+4. Training a final model with the best hyperparameters
+
+Tuned hyperparameters:
+ - embedding_dim: embedding size for code tokens
+ - hidden_dim: GRU hidden state size inside AdaCare
+ - lr: learning rate for AdamW
+ - weight_decay: L2 regularization coefficient for AdamW
+
+Note:
+ AdaCare.__init__ forwards **kwargs to BaseModel.__init__, which only
+ accepts `dataset`. Layer-specific parameters (kernel_size, kernel_num,
+ r_v, r_c, activation, dropout) must not be passed to AdaCare() directly.
+ The tunable surface here covers the explicit named parameters
+ (embedding_dim, hidden_dim) and the optimizer settings.
+"""
+
+import torch
+import optuna
+
+from pyhealth.datasets import (
+ MIMIC4Dataset,
+ get_dataloader,
+ split_by_patient,
+)
+from pyhealth.models import AdaCare
+from pyhealth.tasks import DrugRecommendationMIMIC4
+from pyhealth.trainer import Trainer
+
+if __name__ == "__main__":
+ # ---------------------------------------------------------------------------
+ # STEP 1: Load MIMIC-IV base dataset
+ # ---------------------------------------------------------------------------
+ base_dataset = MIMIC4Dataset(
+ ehr_root="/srv/local/data/physionet.org/files/mimiciv/2.2/",
+ cache_dir="/shared/eng/pyhealth_agent/baselines",
+ ehr_tables=[
+ "patients",
+ "admissions",
+ "diagnoses_icd",
+ "procedures_icd",
+ "prescriptions",
+ ],
+ )
+
+ # STEP 2: Apply drug recommendation task
+ sample_dataset = base_dataset.set_task(
+ DrugRecommendationMIMIC4(),
+ num_workers=4,
+ )
+
+ print(f"Total samples: {len(sample_dataset)}")
+ print(f"Input schema: {sample_dataset.input_schema}")
+ print(f"Output schema: {sample_dataset.output_schema}")
+
+ # STEP 3: Split dataset (fixed split so all trials see the same data)
+ train_dataset, val_dataset, test_dataset = split_by_patient(
+ sample_dataset, [0.8, 0.1, 0.1]
+ )
+
+ print(f"\nDataset split — Train: {len(train_dataset)} "
+ f"Val: {len(val_dataset)} Test: {len(test_dataset)}")
+
+ # ---------------------------------------------------------------------------
+ # STEP 4: Define Optuna objective
+ # ---------------------------------------------------------------------------
+ DEVICE = "cuda:3" # or "cpu"
+ TUNE_EPOCHS = 10 # lightweight training per trial
+ N_TRIALS = 10
+
+ def objective(trial: optuna.Trial) -> float:
+ """Suggest hyperparameters and return val pr_auc_samples."""
+
+ # --- Suggest hyperparameters ---------------------------------------
+ embedding_dim = trial.suggest_categorical(
+ "embedding_dim", [64, 128, 256]
+ )
+ hidden_dim = trial.suggest_categorical("hidden_dim", [64, 128, 256])
+ lr = trial.suggest_float("lr", 1e-5, 1e-2, log=True)
+ weight_decay = trial.suggest_float(
+ "weight_decay", 1e-6, 1e-2, log=True
+ )
+ batch_size = trial.suggest_categorical("batch_size", [32, 64, 128])
+
+ # --- Build dataloaders ---------------------------------------------
+ train_loader = get_dataloader(
+ train_dataset, batch_size=batch_size, shuffle=True
+ )
+ val_loader = get_dataloader(
+ val_dataset, batch_size=batch_size, shuffle=False
+ )
+
+ # --- Build model ---------------------------------------------------
+ model = AdaCare(
+ dataset=sample_dataset,
+ embedding_dim=embedding_dim,
+ hidden_dim=hidden_dim,
+ )
+
+ # --- Train ---------------------------------------------------------
+ trainer = Trainer(
+ model=model,
+ device=DEVICE,
+ metrics=["pr_auc_samples"],
+ )
+ trainer.train(
+ train_dataloader=train_loader,
+ val_dataloader=val_loader,
+ epochs=TUNE_EPOCHS,
+ monitor="pr_auc_samples",
+ optimizer_class=torch.optim.AdamW,
+ optimizer_params={"lr": lr},
+ weight_decay=weight_decay,
+ )
+
+ # --- Evaluate on validation set ------------------------------------
+ scores = trainer.evaluate(val_loader)
+ return scores["pr_auc_samples"]
+
+ # ---------------------------------------------------------------------------
+ # STEP 5: Run Optuna study
+ # ---------------------------------------------------------------------------
+ print(
+ f"\nStarting Optuna search: "
+ f"{N_TRIALS} trials, {TUNE_EPOCHS} epochs each..."
+ )
+
+ study = optuna.create_study(direction="maximize")
+ study.optimize(objective, n_trials=N_TRIALS)
+
+ best_params = study.best_params
+ print("\nBest hyperparameters found:")
+ for k, v in best_params.items():
+ print(f" {k}: {v}")
+ print(f"Best validation pr_auc_samples: {study.best_value:.4f}")
+
+ # ---------------------------------------------------------------------------
+ # STEP 6: Train final model with best hyperparameters
+ # ---------------------------------------------------------------------------
+ print("\nTraining final model with best hyperparameters...")
+
+ train_loader = get_dataloader(
+ train_dataset, batch_size=best_params["batch_size"], shuffle=True
+ )
+ val_loader = get_dataloader(
+ val_dataset, batch_size=best_params["batch_size"], shuffle=False
+ )
+ test_loader = get_dataloader(
+ test_dataset, batch_size=best_params["batch_size"], shuffle=False
+ )
+
+ final_model = AdaCare(
+ dataset=sample_dataset,
+ embedding_dim=best_params["embedding_dim"],
+ hidden_dim=best_params["hidden_dim"],
+ )
+
+ num_params = sum(p.numel() for p in final_model.parameters())
+ print(f"Final model: {num_params:,} parameters")
+
+ final_trainer = Trainer(
+ model=final_model,
+ device=DEVICE,
+ metrics=["pr_auc_samples", "f1_samples", "jaccard_samples"],
+ )
+ final_trainer.train(
+ train_dataloader=train_loader,
+ val_dataloader=val_loader,
+ epochs=50,
+ monitor="pr_auc_samples",
+ optimizer_class=torch.optim.AdamW,
+ optimizer_params={"lr": best_params["lr"]},
+ weight_decay=best_params["weight_decay"],
+ )
+
+ # STEP 7: Evaluate on test set
+ print("\nEvaluating on test set...")
+ results = final_trainer.evaluate(test_loader)
+ print("\nTest Results:")
+ for metric, value in results.items():
+ print(f" {metric}: {value:.4f}")
+
+ # STEP 8: Inspect model predictions
+ print("\nSample predictions:")
+ sample_batch = next(iter(test_loader))
+
+ with torch.no_grad():
+ output = final_model(**sample_batch)
+
+ print(f" Batch size: {output['y_prob'].shape[0]}")
+ print(f" Number of drug classes: {output['y_prob'].shape[1]}")
+ print(" Predicted probabilities (first 5 drugs of first patient):")
+ print(f" {output['y_prob'][0, :5].cpu().numpy()}")
+ print(" True labels (first 5 drugs of first patient):")
+ print(f" {output['y_true'][0, :5].cpu().numpy()}")
diff --git a/examples/drug_recommendation/drug_recommendation_mimic4_gamenet.py b/examples/drug_recommendation/drug_recommendation_mimic4_gamenet.py
index 6aeb2c8ce..bd5b33cb0 100644
--- a/examples/drug_recommendation/drug_recommendation_mimic4_gamenet.py
+++ b/examples/drug_recommendation/drug_recommendation_mimic4_gamenet.py
@@ -25,9 +25,6 @@ def prepare_drug_task_data():
mimicvi = MIMIC4Dataset(
root="/srv/local/data/physionet.org/files/mimiciv/2.0/hosp",
tables=["diagnoses_icd", "procedures_icd", "prescriptions"],
- code_mapping={"NDC": ("ATC", {"target_kwargs": {"level": 3}})},
- dev=_DEV,
- refresh_cache=False,
)
print("stat")
diff --git a/examples/drug_recommendation/drug_recommendation_mimic4_multimodal_retain.py b/examples/drug_recommendation/drug_recommendation_mimic4_multimodal_retain.py
new file mode 100644
index 000000000..a47cce1f0
--- /dev/null
+++ b/examples/drug_recommendation/drug_recommendation_mimic4_multimodal_retain.py
@@ -0,0 +1,210 @@
+"""
+Drug Recommendation on MIMIC-IV with MultimodalRETAIN
+
+This example demonstrates how to use the MultimodalRETAIN model with mixed
+input modalities for drug recommendation on MIMIC-IV.
+
+The MultimodalRETAIN model can handle:
+- Sequential features (visit histories with diagnoses, procedures) → RETAIN processing
+ with reverse time attention mechanism
+- Non-sequential features (demographics, static measurements) → Direct embedding
+
+This example shows:
+1. Loading MIMIC-IV data with mixed feature types
+2. Applying a drug recommendation task
+3. Training a MultimodalRETAIN model with both sequential and non-sequential inputs
+4. Evaluating the model performance
+5. Comparing to vanilla RETAIN (sequential only)
+"""
+
+from pyhealth.datasets import MIMIC4Dataset
+from pyhealth.datasets import split_by_patient, get_dataloader
+from pyhealth.models import MultimodalRETAIN
+from pyhealth.tasks import DrugRecommendationMIMIC4
+from pyhealth.trainer import Trainer
+
+
+if __name__ == "__main__":
+ # STEP 1: Load MIMIC-IV base dataset
+ print("=" * 60)
+ print("STEP 1: Loading MIMIC-IV Dataset")
+ print("=" * 60)
+
+ base_dataset = MIMIC4Dataset(
+ ehr_root="/srv/local/data/physionet.org/files/mimiciv/2.2/",
+ ehr_tables=["diagnoses_icd", "procedures_icd", "prescriptions"],
+ dev=True, # Use development mode for faster testing
+ num_workers=4,
+ )
+ base_dataset.stats()
+
+ # STEP 2: Apply drug recommendation task with multimodal features
+ print("\n" + "=" * 60)
+ print("STEP 2: Setting Drug Recommendation Task")
+ print("=" * 60)
+
+ # Use the DrugRecommendationMIMIC4 task
+ # This task creates visit-level nested sequences from diagnoses/procedures
+ # and recommends drugs for the current visit
+ task = DrugRecommendationMIMIC4()
+ sample_dataset = base_dataset.set_task(
+ task,
+ num_workers=4,
+ )
+
+ print(f"\nTotal samples: {len(sample_dataset)}")
+ print(f"Input schema: {sample_dataset.input_schema}")
+ print(f"Output schema: {sample_dataset.output_schema}")
+
+ # Inspect a sample
+ if len(sample_dataset) > 0:
+ sample = sample_dataset[0]
+ print("\nSample structure:")
+ print(f" Patient ID: {sample['patient_id']}")
+ for key in sample_dataset.input_schema.keys():
+ if key in sample:
+ if isinstance(sample[key], (list, tuple)):
+ if sample[key] and isinstance(sample[key][0], (list, tuple)):
+ print(f" {key}: {len(sample[key])} visits")
+ else:
+ print(f" {key}: length {len(sample[key])}")
+ else:
+ print(f" {key}: {type(sample[key])}")
+ # Show drugs key from output
+ if 'drugs' in sample:
+ print(f" drugs (target): {len(sample['drugs'])} prescriptions")
+
+ # STEP 3: Split dataset
+ print("\n" + "=" * 60)
+ print("STEP 3: Splitting Dataset")
+ print("=" * 60)
+
+ train_dataset, val_dataset, test_dataset = split_by_patient(
+ sample_dataset, [0.8, 0.1, 0.1]
+ )
+
+ print(f"Train samples: {len(train_dataset)}")
+ print(f"Val samples: {len(val_dataset)}")
+ print(f"Test samples: {len(test_dataset)}")
+
+ # Create dataloaders
+ train_loader = get_dataloader(train_dataset, batch_size=64, shuffle=True)
+ val_loader = get_dataloader(val_dataset, batch_size=64, shuffle=False)
+ test_loader = get_dataloader(test_dataset, batch_size=64, shuffle=False)
+
+ # STEP 4: Initialize MultimodalRETAIN model
+ print("\n" + "=" * 60)
+ print("STEP 4: Initializing MultimodalRETAIN Model")
+ print("=" * 60)
+
+ model = MultimodalRETAIN(
+ dataset=sample_dataset,
+ embedding_dim=128,
+ dropout=0.5,
+ )
+
+ num_params = sum(p.numel() for p in model.parameters())
+ print(f"Model initialized with {num_params:,} parameters")
+
+ # Print feature classification
+ print(f"\nSequential features (RETAIN processing): {model.sequential_features}")
+ print(f"Non-sequential features (direct embedding): {model.non_sequential_features}")
+
+ # Calculate expected embedding dimensions
+ total_dim = len(model.feature_keys) * model.embedding_dim
+ print(f"\nPatient representation dimension: {total_dim}")
+
+ # STEP 5: Train the model
+ print("\n" + "=" * 60)
+ print("STEP 5: Training Model")
+ print("=" * 60)
+
+ trainer = Trainer(
+ model=model,
+ device="cuda:0", # Change to "cpu" if no GPU available
+ metrics=["pr_auc_samples", "roc_auc_samples", "jaccard_samples", "f1_samples"],
+ )
+
+ trainer.train(
+ train_dataloader=train_loader,
+ val_dataloader=val_loader,
+ epochs=10,
+ monitor="jaccard_samples",
+ optimizer_params={"lr": 1e-3},
+ )
+
+ # STEP 6: Evaluate on test set
+ print("\n" + "=" * 60)
+ print("STEP 6: Evaluating on Test Set")
+ print("=" * 60)
+
+ results = trainer.evaluate(test_loader)
+ print("\nTest Results:")
+ for metric, value in results.items():
+ print(f" {metric}: {value:.4f}")
+
+ # STEP 7: Demonstrate model predictions
+ print("\n" + "=" * 60)
+ print("STEP 7: Sample Predictions")
+ print("=" * 60)
+
+ import torch
+
+ sample_batch = next(iter(test_loader))
+ with torch.no_grad():
+ output = model(**sample_batch)
+
+ print(f"\nBatch size: {output['y_prob'].shape[0]}")
+ print(f"Output shape: {output['y_prob'].shape}")
+ print(f"(batch_size, num_drug_types)")
+
+ # Show first patient predictions
+ print(f"\nFirst patient top-5 drug recommendations:")
+ first_patient_probs = output['y_prob'][0]
+ top5_drugs = torch.topk(first_patient_probs, k=min(5, len(first_patient_probs)))
+ for i, (drug_idx, prob) in enumerate(zip(top5_drugs.indices, top5_drugs.values)):
+ print(f" {i+1}. Drug index {drug_idx.item()}: probability {prob.item():.4f}")
+
+ # Show ground truth for first patient
+ print(f"\nFirst patient ground truth drugs:")
+ first_patient_true = output['y_true'][0]
+ true_drug_indices = torch.where(first_patient_true > 0)[0]
+ print(f" Number of prescribed drugs: {len(true_drug_indices)}")
+ if len(true_drug_indices) > 0:
+ print(f" Drug indices: {true_drug_indices.tolist()[:10]}...")
+
+ # STEP 8: Compare with vanilla RETAIN (if applicable)
+ print("\n" + "=" * 60)
+ print("STEP 8: Model Architecture Comparison")
+ print("=" * 60)
+
+ print("\nMultimodalRETAIN vs. Vanilla RETAIN:")
+ print(" Vanilla RETAIN:")
+ print(" - Only handles sequential (visit-level) features")
+ print(" - Processes all features through reverse time attention")
+ print(" ")
+ print(" MultimodalRETAIN:")
+ print(" - Handles both sequential and non-sequential features")
+ print(f" - Sequential features ({len(model.sequential_features)}): "
+ f"{model.sequential_features}")
+ print(f" - Non-sequential features ({len(model.non_sequential_features)}): "
+ f"{model.non_sequential_features}")
+ print(" - More flexible for heterogeneous EHR data")
+
+ # Summary
+ print("\n" + "=" * 60)
+ print("SUMMARY: MultimodalRETAIN Training Complete")
+ print("=" * 60)
+ print(f"Model: MultimodalRETAIN")
+ print(f"Dataset: MIMIC-IV")
+ print(f"Task: Drug Recommendation")
+ print(f"Sequential features: {len(model.sequential_features)}")
+ print(f"Non-sequential features: {len(model.non_sequential_features)}")
+ print(f"Best validation Jaccard: {results.get('jaccard_samples', 0):.4f}")
+ print("\nRETAIN advantages:")
+ print(" - Reverse time attention for interpretability")
+ print(" - Visit-level attention weights (alpha)")
+ print(" - Variable-level attention weights (beta)")
+ print(" - Multimodal extension allows richer feature sets")
+ print("=" * 60)
+
diff --git a/examples/drug_recommendation/drug_recommendation_mimic4_retain.py b/examples/drug_recommendation/drug_recommendation_mimic4_retain.py
index 496edc7e3..d39c46042 100644
--- a/examples/drug_recommendation/drug_recommendation_mimic4_retain.py
+++ b/examples/drug_recommendation/drug_recommendation_mimic4_retain.py
@@ -19,100 +19,102 @@
from pyhealth.tasks import DrugRecommendationMIMIC4
from pyhealth.trainer import Trainer
-# STEP 1: Load MIMIC-IV base dataset
-base_dataset = MIMIC4Dataset(
- ehr_root="/srv/local/data/physionet.org/files/mimiciv/2.2/",
- ehr_tables=[
- "patients",
- "admissions",
- "diagnoses_icd",
- "procedures_icd",
- "prescriptions",
- ],
-)
-
-# STEP 2: Apply drug recommendation task
-sample_dataset = base_dataset.set_task(
- DrugRecommendationMIMIC4(),
- num_workers=4,
-)
-
-print(f"Total samples: {len(sample_dataset)}")
-print(f"Input schema: {sample_dataset.input_schema}")
-print(f"Output schema: {sample_dataset.output_schema}")
-
-# Inspect a sample
-sample = sample_dataset.samples[0]
-print("\nSample structure:")
-print(f" Patient ID: {sample['patient_id']}")
-print(f" Visit ID: {sample['visit_id']}")
-print(f" Conditions (history): {len(sample['conditions'])} visits")
-print(f" Procedures (history): {len(sample['procedures'])} visits")
-print(f" Drugs history: {len(sample['drugs_hist'])} visits")
-print(f" Target drugs: {len(sample['drugs'])} drugs")
-print(f"\n First visit conditions: {sample['conditions'][0][:5]}...")
-print(f" Target drugs sample: {sample['drugs'][:5]}...")
-
-# STEP 3: Split dataset
-train_dataset, val_dataset, test_dataset = split_by_patient(
- sample_dataset, [0.8, 0.1, 0.1]
-)
-
-print("\nDataset split:")
-print(f" Train: {len(train_dataset)} samples")
-print(f" Validation: {len(val_dataset)} samples")
-print(f" Test: {len(test_dataset)} samples")
-
-# Create dataloaders
-train_loader = get_dataloader(train_dataset, batch_size=64, shuffle=True)
-val_loader = get_dataloader(val_dataset, batch_size=64, shuffle=False)
-test_loader = get_dataloader(test_dataset, batch_size=64, shuffle=False)
-
-# STEP 4: Initialize RETAIN model
-model = RETAIN(
- dataset=sample_dataset,
- embedding_dim=128,
- dropout=0.5,
-)
-
-num_params = sum(p.numel() for p in model.parameters())
-print(f"\nModel initialized with {num_params:,} parameters")
-print(f"Feature keys: {model.feature_keys}")
-print(f"Label key: {model.label_key}")
-
-# STEP 5: Train the model
-trainer = Trainer(
- model=model,
- device="cuda:4", # or "cpu"
- metrics=["pr_auc_samples", "f1_samples", "jaccard_samples"],
-)
-
-print("\nStarting training...")
-trainer.train(
- train_dataloader=train_loader,
- val_dataloader=val_loader,
- epochs=50,
- monitor="pr_auc_samples",
- optimizer_params={"lr": 1e-3},
-)
-
-# STEP 6: Evaluate on test set
-print("\nEvaluating on test set...")
-results = trainer.evaluate(test_loader)
-print("\nTest Results:")
-for metric, value in results.items():
- print(f" {metric}: {value:.4f}")
-
-# STEP 7: Inspect model predictions
-print("\nSample predictions:")
-sample_batch = next(iter(test_loader))
-
-with torch.no_grad():
- output = model(**sample_batch)
-
-print(f" Batch size: {output['y_prob'].shape[0]}")
-print(f" Number of drug classes: {output['y_prob'].shape[1]}")
-print(" Predicted probabilities (first 5 drugs of first patient):")
-print(f" {output['y_prob'][0, :5].cpu().numpy()}")
-print(" True labels (first 5 drugs of first patient):")
-print(f" {output['y_true'][0, :5].cpu().numpy()}")
+if __name__ == "__main__":
+ # STEP 1: Load MIMIC-IV base dataset
+ base_dataset = MIMIC4Dataset(
+ ehr_root="/srv/local/data/physionet.org/files/mimiciv/2.2/",
+ cache_dir="/shared/eng/pyhealth_agent/baselines",
+ ehr_tables=[
+ "patients",
+ "admissions",
+ "diagnoses_icd",
+ "procedures_icd",
+ "prescriptions",
+ ],
+ )
+
+ # STEP 2: Apply drug recommendation task
+ sample_dataset = base_dataset.set_task(
+ DrugRecommendationMIMIC4(),
+ num_workers=4,
+ )
+
+ print(f"Total samples: {len(sample_dataset)}")
+ print(f"Input schema: {sample_dataset.input_schema}")
+ print(f"Output schema: {sample_dataset.output_schema}")
+
+ # Inspect a sample
+ sample = sample_dataset[0]
+ print("\nSample structure:")
+ print(f" Patient ID: {sample['patient_id']}")
+ print(f" Visit ID: {sample['visit_id']}")
+ print(f" Conditions (history): {len(sample['conditions'])} visits")
+ print(f" Procedures (history): {len(sample['procedures'])} visits")
+ print(f" Drugs history: {len(sample['drugs_hist'])} visits")
+ print(f" Target drugs: {len(sample['drugs'])} drugs")
+ print(f"\n First visit conditions: {sample['conditions'][0][:5]}...")
+ print(f" Target drugs sample: {sample['drugs'][:5]}...")
+
+ # STEP 3: Split dataset
+ train_dataset, val_dataset, test_dataset = split_by_patient(
+ sample_dataset, [0.8, 0.1, 0.1]
+ )
+
+ print("\nDataset split:")
+ print(f" Train: {len(train_dataset)} samples")
+ print(f" Validation: {len(val_dataset)} samples")
+ print(f" Test: {len(test_dataset)} samples")
+
+ # Create dataloaders
+ train_loader = get_dataloader(train_dataset, batch_size=64, shuffle=True)
+ val_loader = get_dataloader(val_dataset, batch_size=64, shuffle=False)
+ test_loader = get_dataloader(test_dataset, batch_size=64, shuffle=False)
+
+ # STEP 4: Initialize RETAIN model
+ model = RETAIN(
+ dataset=sample_dataset,
+ embedding_dim=128,
+ dropout=0.5,
+ )
+
+ num_params = sum(p.numel() for p in model.parameters())
+ print(f"\nModel initialized with {num_params:,} parameters")
+ print(f"Feature keys: {model.feature_keys}")
+ print(f"Label key: {model.label_key}")
+
+ # STEP 5: Train the model
+ trainer = Trainer(
+ model=model,
+ device="cuda:4", # or "cpu"
+ metrics=["pr_auc_samples", "f1_samples", "jaccard_samples"],
+ )
+
+ print("\nStarting training...")
+ trainer.train(
+ train_dataloader=train_loader,
+ val_dataloader=val_loader,
+ epochs=50,
+ monitor="pr_auc_samples",
+ optimizer_params={"lr": 1e-3},
+ )
+
+ # STEP 6: Evaluate on test set
+ print("\nEvaluating on test set...")
+ results = trainer.evaluate(test_loader)
+ print("\nTest Results:")
+ for metric, value in results.items():
+ print(f" {metric}: {value:.4f}")
+
+ # STEP 7: Inspect model predictions
+ print("\nSample predictions:")
+ sample_batch = next(iter(test_loader))
+
+ with torch.no_grad():
+ output = model(**sample_batch)
+
+ print(f" Batch size: {output['y_prob'].shape[0]}")
+ print(f" Number of drug classes: {output['y_prob'].shape[1]}")
+ print(" Predicted probabilities (first 5 drugs of first patient):")
+ print(f" {output['y_prob'][0, :5].cpu().numpy()}")
+ print(" True labels (first 5 drugs of first patient):")
+ print(f" {output['y_true'][0, :5].cpu().numpy()}")
diff --git a/examples/drug_recommendation/drug_recommendation_mimic4_rnn.py b/examples/drug_recommendation/drug_recommendation_mimic4_rnn.py
new file mode 100644
index 000000000..5f8f38e08
--- /dev/null
+++ b/examples/drug_recommendation/drug_recommendation_mimic4_rnn.py
@@ -0,0 +1,123 @@
+"""
+Example of using RNN for drug recommendation on MIMIC-IV.
+
+This example demonstrates:
+1. Loading MIMIC-IV data
+2. Applying the DrugRecommendationMIMIC4 task
+3. Creating a SampleDataset with nested sequence processors
+4. Training an RNN model
+"""
+
+import torch
+
+from pyhealth.datasets import (
+ MIMIC4Dataset,
+ get_dataloader,
+ split_by_patient,
+)
+from pyhealth.models import RNN
+from pyhealth.tasks import DrugRecommendationMIMIC4
+from pyhealth.trainer import Trainer
+
+if __name__ == "__main__":
+ # STEP 1: Load MIMIC-IV base dataset
+ base_dataset = MIMIC4Dataset(
+ ehr_root="/srv/local/data/physionet.org/files/mimiciv/2.2/",
+ cache_dir="/shared/eng/pyhealth_agent/baselines",
+ ehr_tables=[
+ "patients",
+ "admissions",
+ "diagnoses_icd",
+ "procedures_icd",
+ "prescriptions",
+ ],
+ )
+
+ # STEP 2: Apply drug recommendation task
+ sample_dataset = base_dataset.set_task(
+ DrugRecommendationMIMIC4(),
+ num_workers=4,
+ )
+
+ print(f"Total samples: {len(sample_dataset)}")
+ print(f"Input schema: {sample_dataset.input_schema}")
+ print(f"Output schema: {sample_dataset.output_schema}")
+
+ # Inspect a sample
+ sample = sample_dataset[0]
+ print("\nSample structure:")
+ print(f" Patient ID: {sample['patient_id']}")
+ print(f" Visit ID: {sample['visit_id']}")
+ print(f" Conditions (history): {len(sample['conditions'])} visits")
+ print(f" Procedures (history): {len(sample['procedures'])} visits")
+ print(f" Drugs history: {len(sample['drugs_hist'])} visits")
+ print(f" Target drugs: {len(sample['drugs'])} drugs")
+ print(f"\n First visit conditions: {sample['conditions'][0][:5]}...")
+ print(f" Target drugs sample: {sample['drugs'][:5]}...")
+
+ # STEP 3: Split dataset
+ train_dataset, val_dataset, test_dataset = split_by_patient(
+ sample_dataset, [0.8, 0.1, 0.1]
+ )
+
+ print("\nDataset split:")
+ print(f" Train: {len(train_dataset)} samples")
+ print(f" Validation: {len(val_dataset)} samples")
+ print(f" Test: {len(test_dataset)} samples")
+
+ # Create dataloaders
+ train_loader = get_dataloader(train_dataset, batch_size=64, shuffle=True)
+ val_loader = get_dataloader(val_dataset, batch_size=64, shuffle=False)
+ test_loader = get_dataloader(test_dataset, batch_size=64, shuffle=False)
+
+ # STEP 4: Initialize RNN model
+ model = RNN(
+ dataset=sample_dataset,
+ embedding_dim=128,
+ hidden_dim=128,
+ rnn_type="GRU",
+ dropout=0.5,
+ )
+
+ num_params = sum(p.numel() for p in model.parameters())
+ print(f"\nModel initialized with {num_params:,} parameters")
+ print(f"Feature keys: {model.feature_keys}")
+ print(f"Label key: {model.label_key}")
+
+ # STEP 5: Train the model
+ trainer = Trainer(
+ model=model,
+ device="cuda:0", # or "cpu"
+ metrics=["pr_auc_samples", "f1_samples", "jaccard_samples"],
+ )
+
+ print("\nStarting training...")
+ trainer.train(
+ train_dataloader=train_loader,
+ val_dataloader=val_loader,
+ epochs=50,
+ monitor="pr_auc_samples",
+ optimizer_params={"lr": 1e-4},
+ optimizer_class=torch.optim.AdamW,
+ )
+
+ # STEP 6: Evaluate on test set
+ print("\nEvaluating on test set...")
+ results = trainer.evaluate(test_loader)
+ print("\nTest Results:")
+ for metric, value in results.items():
+ print(f" {metric}: {value:.4f}")
+
+ # STEP 7: Inspect model predictions
+ print("\nSample predictions:")
+ sample_batch = next(iter(test_loader))
+
+ with torch.no_grad():
+ output = model(**sample_batch)
+
+ print(f" Batch size: {output['y_prob'].shape[0]}")
+ print(f" Number of drug classes: {output['y_prob'].shape[1]}")
+ print(" Predicted probabilities (first 5 drugs of first patient):")
+ print(f" {output['y_prob'][0, :5].cpu().numpy()}")
+ print(" True labels (first 5 drugs of first patient):")
+ print(f" {output['y_true'][0, :5].cpu().numpy()}")
diff --git a/examples/drug_recommendation/drug_recommendation_mimic4_rnn_optuna.py b/examples/drug_recommendation/drug_recommendation_mimic4_rnn_optuna.py
new file mode 100644
index 000000000..4c3fb2a9f
--- /dev/null
+++ b/examples/drug_recommendation/drug_recommendation_mimic4_rnn_optuna.py
@@ -0,0 +1,205 @@
+"""
+Optuna hyperparameter tuning for RNN on drug recommendation with MIMIC-IV.
+
+This example demonstrates:
+1. Loading MIMIC-IV data and applying the DrugRecommendationMIMIC4 task
+2. Defining an Optuna objective that tunes RNN-specific hyperparameters
+3. Running 10 Optuna trials to find the best configuration
+4. Training a final model with the best hyperparameters
+
+Tuned hyperparameters:
+ - embedding_dim: embedding size for code tokens
+ - hidden_dim: GRU/LSTM/RNN hidden state size
+ - rnn_type: recurrent cell type (GRU, LSTM, RNN)
+ - num_layers: number of stacked recurrent layers
+ - dropout: dropout rate applied before each recurrent layer
+ - lr: learning rate for AdamW
+ - weight_decay: L2 regularization coefficient for AdamW
+"""
+
+import torch
+import optuna
+
+from pyhealth.datasets import (
+ MIMIC4Dataset,
+ get_dataloader,
+ split_by_patient,
+)
+from pyhealth.models import RNN
+from pyhealth.tasks import DrugRecommendationMIMIC4
+from pyhealth.trainer import Trainer
+
+if __name__ == "__main__":
+ # ---------------------------------------------------------------------------
+ # STEP 1: Load MIMIC-IV base dataset
+ # ---------------------------------------------------------------------------
+ base_dataset = MIMIC4Dataset(
+ ehr_root="/srv/local/data/physionet.org/files/mimiciv/2.2/",
+ cache_dir="/shared/eng/pyhealth_agent/baselines",
+ ehr_tables=[
+ "patients",
+ "admissions",
+ "diagnoses_icd",
+ "procedures_icd",
+ "prescriptions",
+ ],
+ )
+
+ # STEP 2: Apply drug recommendation task
+ sample_dataset = base_dataset.set_task(
+ DrugRecommendationMIMIC4(),
+ num_workers=4,
+ )
+
+ print(f"Total samples: {len(sample_dataset)}")
+ print(f"Input schema: {sample_dataset.input_schema}")
+ print(f"Output schema: {sample_dataset.output_schema}")
+
+ # STEP 3: Split dataset (fixed split so all trials see the same data)
+ train_dataset, val_dataset, test_dataset = split_by_patient(
+ sample_dataset, [0.8, 0.1, 0.1]
+ )
+
+ print(f"\nDataset split — Train: {len(train_dataset)} "
+ f"Val: {len(val_dataset)} Test: {len(test_dataset)}")
+
+ # ---------------------------------------------------------------------------
+ # STEP 4: Define Optuna objective
+ # ---------------------------------------------------------------------------
+ DEVICE = "cuda:2" # or "cpu"
+ TUNE_EPOCHS = 10 # lightweight training per trial
+ N_TRIALS = 10
+
+ def objective(trial: optuna.Trial) -> float:
+ """Return validation pr_auc_samples for a sampled RNN configuration."""
+
+ # --- Suggest hyperparameters -------------------------------------------
+ embedding_dim = trial.suggest_categorical(
+ "embedding_dim", [64, 128, 256]
+ )
+ hidden_dim = trial.suggest_categorical("hidden_dim", [64, 128, 256])
+ rnn_type = trial.suggest_categorical(
+ "rnn_type", ["GRU", "LSTM", "RNN"]
+ )
+ num_layers = trial.suggest_int("num_layers", 1, 3)
+ dropout = trial.suggest_float("dropout", 0.1, 0.7)
+ lr = trial.suggest_float("lr", 1e-5, 1e-2, log=True)
+ weight_decay = trial.suggest_float("weight_decay", 1e-6, 1e-2, log=True)
+ batch_size = trial.suggest_categorical("batch_size", [32, 64, 128])
+
+ # --- Build dataloaders -------------------------------------------------
+ train_loader = get_dataloader(
+ train_dataset, batch_size=batch_size, shuffle=True
+ )
+ val_loader = get_dataloader(
+ val_dataset, batch_size=batch_size, shuffle=False
+ )
+
+ # --- Build model -------------------------------------------------------
+ model = RNN(
+ dataset=sample_dataset,
+ embedding_dim=embedding_dim,
+ hidden_dim=hidden_dim,
+ rnn_type=rnn_type,
+ num_layers=num_layers,
+ dropout=dropout,
+ )
+
+ # --- Train -------------------------------------------------------------
+ trainer = Trainer(
+ model=model,
+ device=DEVICE,
+ metrics=["pr_auc_samples"],
+ )
+ trainer.train(
+ train_dataloader=train_loader,
+ val_dataloader=val_loader,
+ epochs=TUNE_EPOCHS,
+ monitor="pr_auc_samples",
+ optimizer_class=torch.optim.AdamW,
+ optimizer_params={"lr": lr},
+ weight_decay=weight_decay,
+ )
+
+ # --- Evaluate on validation set ----------------------------------------
+ scores = trainer.evaluate(val_loader)
+ return scores["pr_auc_samples"]
+
+ # ---------------------------------------------------------------------------
+ # STEP 5: Run Optuna study
+ # ---------------------------------------------------------------------------
+ print(
+ f"\nStarting Optuna search ({N_TRIALS} trials, {TUNE_EPOCHS} epochs each)..."
+ )
+
+ study = optuna.create_study(direction="maximize")
+ study.optimize(objective, n_trials=N_TRIALS)
+
+ best_params = study.best_params
+ print("\nBest hyperparameters found:")
+ for k, v in best_params.items():
+ print(f" {k}: {v}")
+ print(f"Best validation pr_auc_samples: {study.best_value:.4f}")
+
+ # ---------------------------------------------------------------------------
+ # STEP 6: Train final model with best hyperparameters
+ # ---------------------------------------------------------------------------
+ print("\nTraining final model with best hyperparameters...")
+
+ train_loader = get_dataloader(
+ train_dataset, batch_size=best_params["batch_size"], shuffle=True
+ )
+ val_loader = get_dataloader(
+ val_dataset, batch_size=best_params["batch_size"], shuffle=False
+ )
+ test_loader = get_dataloader(
+ test_dataset, batch_size=best_params["batch_size"], shuffle=False
+ )
+
+ final_model = RNN(
+ dataset=sample_dataset,
+ embedding_dim=best_params["embedding_dim"],
+ hidden_dim=best_params["hidden_dim"],
+ rnn_type=best_params["rnn_type"],
+ num_layers=best_params["num_layers"],
+ dropout=best_params["dropout"],
+ )
+
+ num_params = sum(p.numel() for p in final_model.parameters())
+ print(f"Final model: {num_params:,} parameters")
+
+ final_trainer = Trainer(
+ model=final_model,
+ device=DEVICE,
+ metrics=["pr_auc_samples", "f1_samples", "jaccard_samples"],
+ )
+ final_trainer.train(
+ train_dataloader=train_loader,
+ val_dataloader=val_loader,
+ epochs=50,
+ monitor="pr_auc_samples",
+ optimizer_class=torch.optim.AdamW,
+ optimizer_params={"lr": best_params["lr"]},
+ weight_decay=best_params["weight_decay"],
+ )
+
+ # STEP 7: Evaluate on test set
+ print("\nEvaluating on test set...")
+ results = final_trainer.evaluate(test_loader)
+ print("\nTest Results:")
+ for metric, value in results.items():
+ print(f" {metric}: {value:.4f}")
+
+ # STEP 8: Inspect model predictions
+ print("\nSample predictions:")
+ sample_batch = next(iter(test_loader))
+
+ with torch.no_grad():
+ output = final_model(**sample_batch)
+
+ print(f" Batch size: {output['y_prob'].shape[0]}")
+ print(f" Number of drug classes: {output['y_prob'].shape[1]}")
+ print(" Predicted probabilities (first 5 drugs of first patient):")
+ print(f" {output['y_prob'][0, :5].cpu().numpy()}")
+ print(" True labels (first 5 drugs of first patient):")
+ print(f" {output['y_true'][0, :5].cpu().numpy()}")
diff --git a/examples/eeg/eeg_models/TFM_Tokenizer_tuev_eeg_event_classification.ipynb b/examples/eeg/eeg_models/TFM_Tokenizer_tuev_eeg_event_classification.ipynb
new file mode 100644
index 000000000..24dcbc98b
--- /dev/null
+++ b/examples/eeg/eeg_models/TFM_Tokenizer_tuev_eeg_event_classification.ipynb
@@ -0,0 +1,527 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "228ec958",
+ "metadata": {},
+ "source": [
+ "## 1. Environment Setup\n",
+ "Seed the random generators, import core dependencies, and detect the training device."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "ec86f718",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Running on device: cuda\n"
+ ]
+ }
+ ],
+ "source": [
+ "import random\n",
+ "\n",
+ "import numpy as np\n",
+ "import torch\n",
+ "\n",
+ "from pyhealth.datasets import TUEVDataset\n",
+ "from pyhealth.tasks import EEGEventsTUEV\n",
+ "from pyhealth.datasets.splitter import split_by_sample\n",
+ "from pyhealth.datasets.utils import get_dataloader\n",
+ "from pyhealth.models import TFMTokenizer\n",
+ "\n",
+ "SEED = 5\n",
+ "random.seed(SEED)\n",
+ "np.random.seed(SEED)\n",
+ "torch.manual_seed(SEED)\n",
+ "if torch.cuda.is_available():\n",
+ " torch.cuda.manual_seed_all(SEED)\n",
+ "\n",
+ "device = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n",
+ "print(f\"Running on device: {device}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "5b5c5039",
+ "metadata": {},
+ "source": [
+ "## 2. Load TUEV Dataset\n",
+ "Point to the TUEV dataset root and load the dataset."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "f3588fff",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "No config path provided, using default config\n",
+ "Using subset: eval\n",
+ "Using cached metadata from /home/jp65/.cache/pyhealth/tuev\n",
+ "Initializing tuev dataset from /home/jp65/.cache/pyhealth/tuev (dev mode: False)\n",
+ "No cache_dir provided. Using default cache dir: /home/jp65/.cache/pyhealth/2e360290-5ee2-591d-aaca-45052d892fb1\n",
+ "Found cached event dataframe: /home/jp65/.cache/pyhealth/2e360290-5ee2-591d-aaca-45052d892fb1/global_event_df.parquet\n",
+ "Dataset: tuev\n",
+ "Dev mode: False\n",
+ "Number of patients: 80\n",
+ "Number of events: 159\n"
+ ]
+ }
+ ],
+ "source": [
+ "dataset = TUEVDataset(\n",
+ " root='/srv/local/data/TUH/tuh_eeg_events/v2.0.0/edf', # Update this path\n",
+ " subset='eval',\n",
+ " # dev=True\n",
+ ")\n",
+ "dataset.stats()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "93a93c5c",
+ "metadata": {},
+ "source": [
+ "## 3. Prepare PyHealth Dataset\n",
+ "Set the task for the dataset and convert raw samples into PyHealth format for abnormal EEG classification."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "02b47b1f",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Setting task EEG_events for tuev base dataset...\n",
+ "Task cache paths: task_df=/home/jp65/.cache/pyhealth/2e360290-5ee2-591d-aaca-45052d892fb1/tasks/EEG_events_107ed06b-ca3c-5d49-87ae-a690ae834ab8/task_df.ld, samples=/home/jp65/.cache/pyhealth/2e360290-5ee2-591d-aaca-45052d892fb1/tasks/EEG_events_107ed06b-ca3c-5d49-87ae-a690ae834ab8/samples_cdbbc602-34e2-5a41-8643-4c76b08829f6.ld\n",
+ "Found cached processed samples at /home/jp65/.cache/pyhealth/2e360290-5ee2-591d-aaca-45052d892fb1/tasks/EEG_events_107ed06b-ca3c-5d49-87ae-a690ae834ab8/samples_cdbbc602-34e2-5a41-8643-4c76b08829f6.ld, skipping processing.\n",
+ "Total task samples: 29421\n",
+ "Input schema: {'signal': 'tensor'}\n",
+ "Output schema: {'label': 'multiclass'}\n",
+ "\n",
+ "Sample keys: dict_keys(['patient_id', 'signal_file', 'signal', 'offending_channel', 'label'])\n",
+ "Signal shape: torch.Size([16, 1000])\n",
+ "Label: 5\n"
+ ]
+ }
+ ],
+ "source": [
+ "sample_dataset = dataset.set_task(EEGEventsTUEV(\n",
+ " resample_rate=200, # Resample rate\n",
+ " bandpass_filter=(0.1, 75.0), # Bandpass filter\n",
+ " notch_filter=50.0, # Notch filter\n",
+ " normalization='95th_percentile'\n",
+ "))\n",
+ "\n",
+ "print(f\"Total task samples: {len(sample_dataset)}\")\n",
+ "print(f\"Input schema: {sample_dataset.input_schema}\")\n",
+ "print(f\"Output schema: {sample_dataset.output_schema}\")\n",
+ "\n",
+ "# Inspect a sample\n",
+ "sample = sample_dataset[0]\n",
+ "print(f\"\\nSample keys: {sample.keys()}\")\n",
+ "print(f\"Signal shape: {sample['signal'].shape}\")\n",
+ "print(f\"Label: {sample['label']}\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 24,
+ "id": "ad2408b2",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Test loader size: 29\n"
+ ]
+ }
+ ],
+ "source": [
+ "test_loader = get_dataloader(\n",
+ " dataset=sample_dataset,\n",
+ " batch_size=1024,\n",
+ " shuffle=False,\n",
+ ")\n",
+ "print(f\"Test loader size: {len(test_loader)}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "b0485321",
+ "metadata": {},
+ "source": [
+ "## 4. Initialize TFM-Tokenizer Model"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "bde0e0d5",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "✓ Successfully loaded weights from /home/jp65/PyHealth/TFM_Tokenizer_multiple_finetuned_on_TUEV_1/best_model.pth\n",
+ "Model created with 1891114 parameters\n"
+ ]
+ }
+ ],
+ "source": [
+ "model = TFMTokenizer(\n",
+ " dataset=sample_dataset,\n",
+ " emb_size=64,\n",
+ " code_book_size=8192,\n",
+ " trans_freq_encoder_depth=2,\n",
+ " trans_temporal_encoder_depth=2,\n",
+ " trans_decoder_depth=8,\n",
+ " use_classifier=True,\n",
+ " classifier_depth=4,\n",
+ ")\n",
+ "\n",
+ "model = model.to(device)\n",
+ "\n",
+ "# tokenizer_checkpoint_path = '[From the TFM-Tokenizer GitHub] - pretrained_weigths/multiple_dataset_settings/Pretrained_tfm_tokenizer_2x2x8/tfm_tokenizer_last.pth'\n",
+ "model.load_pretrained_weights(\n",
+ " tokenizer_checkpoint_path='/home/jp65/Biosignals_Research/tfm_token_code_for_release/TFM-Tokenizer/pretrained_weigths/multiple_dataset_settings/Pretrained_tfm_tokenizer_2x2x8/tfm_tokenizer_last.pth',\n",
+ " classifier_checkpoint_path='/home/jp65/PyHealth/TFM_Tokenizer_multiple_finetuned_on_TUEV_1/best_model.pth',\n",
+ " is_masked_training=False,\n",
+ " strict=False,\n",
+ " map_location=device\n",
+ ")\n",
+ "\n",
+ "print(f\"Model created with {sum(p.numel() for p in model.parameters())} parameters\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "a7d372c9",
+ "metadata": {},
+ "source": [
+ "## 5. Test Forward Pass"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 26,
+ "id": "41cf1e5e",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Output keys: dict_keys(['recon_loss', 'vq_loss', 'tokens', 'embeddings', 'loss', 'cls_loss', 'y_prob', 'y_true', 'logit'])\n",
+ "Loss: 3.5373\n",
+ "Logits shape: torch.Size([1024, 6])\n",
+ "Tokens shape: torch.Size([1024, 16, 9])\n",
+ "Embeddings shape: torch.Size([1024, 16, 9, 64])\n"
+ ]
+ }
+ ],
+ "source": [
+ "batch = next(iter(test_loader))\n",
+ "\n",
+ "with torch.no_grad():\n",
+ " outputs = model(**batch)\n",
+ "\n",
+ "print(\"Output keys:\", outputs.keys())\n",
+ "print(f\"Loss: {outputs['loss'].item():.4f}\")\n",
+ "print(f\"Logits shape: {outputs['logit'].shape}\")\n",
+ "print(f\"Tokens shape: {outputs['tokens'].shape}\")\n",
+ "print(f\"Embeddings shape: {outputs['embeddings'].shape}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "e9e3cd25",
+ "metadata": {},
+ "source": [
+ "# 6. Inference on Test Dataloader"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 27,
+ "id": "d4702152",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "TFMTokenizer(\n",
+ " (tokenizer): TFM_VQVAE2_deep(\n",
+ " (freq_patch_embedding): Sequential(\n",
+ " (0): Conv1d(1, 64, kernel_size=(5,), stride=(5,))\n",
+ " (1): GELU(approximate='none')\n",
+ " (2): GroupNorm(16, 64, eps=1e-05, affine=True)\n",
+ " (3): Conv1d(64, 64, kernel_size=(1,), stride=(1,))\n",
+ " (4): GELU(approximate='none')\n",
+ " (5): GroupNorm(16, 64, eps=1e-05, affine=True)\n",
+ " (6): Conv1d(64, 64, kernel_size=(1,), stride=(1,))\n",
+ " (7): GELU(approximate='none')\n",
+ " (8): GroupNorm(16, 64, eps=1e-05, affine=True)\n",
+ " )\n",
+ " (trans_freq_encoder): TransformerEncoder(\n",
+ " (transformer): LinearAttentionTransformer(\n",
+ " (layers): SequentialSequence(\n",
+ " (layers): ModuleList(\n",
+ " (0-1): 2 x ModuleList(\n",
+ " (0): PreNorm(\n",
+ " (fn): SelfAttention(\n",
+ " (local_attn): LocalAttention(\n",
+ " (dropout): Dropout(p=0.2, inplace=False)\n",
+ " )\n",
+ " (to_q): Linear(in_features=64, out_features=64, bias=False)\n",
+ " (to_k): Linear(in_features=64, out_features=64, bias=False)\n",
+ " (to_v): Linear(in_features=64, out_features=64, bias=False)\n",
+ " (to_out): Linear(in_features=64, out_features=64, bias=True)\n",
+ " (dropout): Dropout(p=0.2, inplace=False)\n",
+ " )\n",
+ " (norm): LayerNorm((64,), eps=1e-05, elementwise_affine=True)\n",
+ " )\n",
+ " (1): PreNorm(\n",
+ " (fn): Chunk(\n",
+ " (fn): FeedForward(\n",
+ " (w1): Linear(in_features=64, out_features=256, bias=True)\n",
+ " (act): GELU(approximate='none')\n",
+ " (dropout): Dropout(p=0.0, inplace=False)\n",
+ " (w2): Linear(in_features=256, out_features=64, bias=True)\n",
+ " )\n",
+ " )\n",
+ " (norm): LayerNorm((64,), eps=1e-05, elementwise_affine=True)\n",
+ " )\n",
+ " )\n",
+ " )\n",
+ " )\n",
+ " )\n",
+ " )\n",
+ " (temporal_patch_embedding): Sequential(\n",
+ " (0): Conv1d(1, 64, kernel_size=(200,), stride=(100,))\n",
+ " (1): GELU(approximate='none')\n",
+ " (2): GroupNorm(16, 64, eps=1e-05, affine=True)\n",
+ " (3): Conv1d(64, 64, kernel_size=(1,), stride=(1,))\n",
+ " (4): GELU(approximate='none')\n",
+ " (5): GroupNorm(16, 64, eps=1e-05, affine=True)\n",
+ " (6): Conv1d(64, 32, kernel_size=(1,), stride=(1,))\n",
+ " (7): GELU(approximate='none')\n",
+ " (8): GroupNorm(16, 32, eps=1e-05, affine=True)\n",
+ " )\n",
+ " (freq_patch_embedding_2_atten): Sequential(\n",
+ " (0): Conv1d(64, 8, kernel_size=(5,), stride=(5,))\n",
+ " (1): Sigmoid()\n",
+ " )\n",
+ " (freq_patch_embedding_2): Sequential(\n",
+ " (0): Conv1d(64, 8, kernel_size=(5,), stride=(5,))\n",
+ " )\n",
+ " (trans_temporal_encoder): TransformerEncoder(\n",
+ " (transformer): LinearAttentionTransformer(\n",
+ " (layers): SequentialSequence(\n",
+ " (layers): ModuleList(\n",
+ " (0-1): 2 x ModuleList(\n",
+ " (0): PreNorm(\n",
+ " (fn): SelfAttention(\n",
+ " (local_attn): LocalAttention(\n",
+ " (dropout): Dropout(p=0.2, inplace=False)\n",
+ " )\n",
+ " (to_q): Linear(in_features=64, out_features=64, bias=False)\n",
+ " (to_k): Linear(in_features=64, out_features=64, bias=False)\n",
+ " (to_v): Linear(in_features=64, out_features=64, bias=False)\n",
+ " (to_out): Linear(in_features=64, out_features=64, bias=True)\n",
+ " (dropout): Dropout(p=0.2, inplace=False)\n",
+ " )\n",
+ " (norm): LayerNorm((64,), eps=1e-05, elementwise_affine=True)\n",
+ " )\n",
+ " (1): PreNorm(\n",
+ " (fn): Chunk(\n",
+ " (fn): FeedForward(\n",
+ " (w1): Linear(in_features=64, out_features=256, bias=True)\n",
+ " (act): GELU(approximate='none')\n",
+ " (dropout): Dropout(p=0.0, inplace=False)\n",
+ " (w2): Linear(in_features=256, out_features=64, bias=True)\n",
+ " )\n",
+ " )\n",
+ " (norm): LayerNorm((64,), eps=1e-05, elementwise_affine=True)\n",
+ " )\n",
+ " )\n",
+ " )\n",
+ " )\n",
+ " )\n",
+ " )\n",
+ " (quantizer): EMAVectorQuantizer(\n",
+ " (embedding): Embedding(8192, 64)\n",
+ " )\n",
+ " (trans_decoder): TransformerEncoder(\n",
+ " (transformer): LinearAttentionTransformer(\n",
+ " (layers): SequentialSequence(\n",
+ " (layers): ModuleList(\n",
+ " (0-7): 8 x ModuleList(\n",
+ " (0): PreNorm(\n",
+ " (fn): SelfAttention(\n",
+ " (local_attn): LocalAttention(\n",
+ " (dropout): Dropout(p=0.2, inplace=False)\n",
+ " )\n",
+ " (to_q): Linear(in_features=64, out_features=64, bias=False)\n",
+ " (to_k): Linear(in_features=64, out_features=64, bias=False)\n",
+ " (to_v): Linear(in_features=64, out_features=64, bias=False)\n",
+ " (to_out): Linear(in_features=64, out_features=64, bias=True)\n",
+ " (dropout): Dropout(p=0.2, inplace=False)\n",
+ " )\n",
+ " (norm): LayerNorm((64,), eps=1e-05, elementwise_affine=True)\n",
+ " )\n",
+ " (1): PreNorm(\n",
+ " (fn): Chunk(\n",
+ " (fn): FeedForward(\n",
+ " (w1): Linear(in_features=64, out_features=256, bias=True)\n",
+ " (act): GELU(approximate='none')\n",
+ " (dropout): Dropout(p=0.0, inplace=False)\n",
+ " (w2): Linear(in_features=256, out_features=64, bias=True)\n",
+ " )\n",
+ " )\n",
+ " (norm): LayerNorm((64,), eps=1e-05, elementwise_affine=True)\n",
+ " )\n",
+ " )\n",
+ " )\n",
+ " )\n",
+ " )\n",
+ " )\n",
+ " (decoder): Sequential(\n",
+ " (0): Linear(in_features=64, out_features=64, bias=True)\n",
+ " (1): Tanh()\n",
+ " (2): Linear(in_features=64, out_features=100, bias=True)\n",
+ " )\n",
+ " )\n",
+ " (classifier): TFM_TOKEN_Classifier(\n",
+ " (eeg_token_embedding): Embedding(8193, 64)\n",
+ " (channel_embed): Embedding(16, 64)\n",
+ " (temporal_pos_embed): PositionalEncoding(\n",
+ " (dropout): Dropout(p=0.1, inplace=False)\n",
+ " )\n",
+ " (pos_drop): Dropout(p=0.1, inplace=False)\n",
+ " (LAT): LinearAttentionTransformer(\n",
+ " (layers): SequentialSequence(\n",
+ " (layers): ModuleList(\n",
+ " (0-3): 4 x ModuleList(\n",
+ " (0): PreNorm(\n",
+ " (fn): SelfAttention(\n",
+ " (local_attn): LocalAttention(\n",
+ " (dropout): Dropout(p=0.2, inplace=False)\n",
+ " )\n",
+ " (to_q): Linear(in_features=64, out_features=64, bias=False)\n",
+ " (to_k): Linear(in_features=64, out_features=64, bias=False)\n",
+ " (to_v): Linear(in_features=64, out_features=64, bias=False)\n",
+ " (to_out): Linear(in_features=64, out_features=64, bias=True)\n",
+ " (dropout): Dropout(p=0.2, inplace=False)\n",
+ " )\n",
+ " (norm): LayerNorm((64,), eps=1e-05, elementwise_affine=True)\n",
+ " )\n",
+ " (1): PreNorm(\n",
+ " (fn): Chunk(\n",
+ " (fn): FeedForward(\n",
+ " (w1): Linear(in_features=64, out_features=256, bias=True)\n",
+ " (act): GELU(approximate='none')\n",
+ " (dropout): Dropout(p=0.0, inplace=False)\n",
+ " (w2): Linear(in_features=256, out_features=64, bias=True)\n",
+ " )\n",
+ " )\n",
+ " (norm): LayerNorm((64,), eps=1e-05, elementwise_affine=True)\n",
+ " )\n",
+ " )\n",
+ " )\n",
+ " )\n",
+ " )\n",
+ " (classification_head): Linear(in_features=64, out_features=6, bias=True)\n",
+ " )\n",
+ ")\n",
+ "Metrics: ['accuracy', 'balanced_accuracy', 'cohen_kappa']\n",
+ "Device: cuda\n",
+ "\n"
+ ]
+ }
+ ],
+ "source": [
+ "from pyhealth.trainer import Trainer\n",
+ "\n",
+ "trainer = Trainer(model=model, device=device,metrics=[\"accuracy\", \"balanced_accuracy\", \"cohen_kappa\"],)\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 28,
+ "id": "42177263",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "Evaluation: 100%|██████████| 29/29 [00:31<00:00, 1.09s/it]\n"
+ ]
+ },
+ {
+ "data": {
+ "text/plain": [
+ "{'accuracy': 0.7779817137418851,\n",
+ " 'balanced_accuracy': 0.5872575097440921,\n",
+ " 'cohen_kappa': 0.5892794513693542,\n",
+ " 'loss': 2.781993134268399}"
+ ]
+ },
+ "execution_count": 28,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "trainer.evaluate(test_loader)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "378ff411",
+ "metadata": {},
+ "outputs": [],
+ "source": []
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "pyhealth",
+ "language": "python",
+ "name": "pyhealth"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.12.12"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/examples/graphcare_tutorial.ipynb b/examples/graphcare_tutorial.ipynb
new file mode 100644
index 000000000..5da8659d0
--- /dev/null
+++ b/examples/graphcare_tutorial.ipynb
@@ -0,0 +1,570 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "bd69b8dc",
+ "metadata": {},
+ "source": [
+ "# GraphCare: End-to-End Example with Synthetic Data\n",
+ "\n",
+ "**Paper:** Jiang et al., \"GraphCare: Enhancing Healthcare Predictions with Personalized Knowledge Graphs\", ICLR 2024\n",
+ "\n",
+ "**Links:** [Paper](https://openreview.net/forum?id=tVTN7Zs0ml) | [Original Repo](https://github.com/pat-jj/GraphCare)\n",
+ "\n",
+ "This notebook demonstrates the full GraphCare pipeline using PyHealth's native graph infrastructure:\n",
+ "\n",
+ "1. Build a small medical knowledge graph\n",
+ "2. Create synthetic patient samples with EHR codes\n",
+ "3. Configure graph-based input schema (GraphProcessor)\n",
+ "4. Train GraphCare with BAT backbone\n",
+ "5. Evaluate on test set\n",
+ "6. Compare GNN backbones (BAT, GAT, GIN)\n",
+ "7. Compare patient representation modes (joint, graph, node)\n",
+ "\n",
+ "**Requirements:** `pip install torch-geometric`"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "22141d98",
+ "metadata": {},
+ "source": [
+ "## 1. Environment Setup"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "2d4895f1",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import random\n",
+ "\n",
+ "import numpy as np\n",
+ "import torch\n",
+ "\n",
+ "from pyhealth.datasets import (\n",
+ " create_sample_dataset,\n",
+ " get_dataloader,\n",
+ " split_by_sample,\n",
+ ")\n",
+ "from pyhealth.graph import KnowledgeGraph\n",
+ "from pyhealth.models.graphcare import GraphCare\n",
+ "from pyhealth.trainer import Trainer\n",
+ "\n",
+ "SEED = 42\n",
+ "random.seed(SEED)\n",
+ "np.random.seed(SEED)\n",
+ "torch.manual_seed(SEED)\n",
+ "\n",
+ "DEVICE = \"cuda\" if torch.cuda.is_available() else \"cpu\"\n",
+ "print(f\"Device: {DEVICE}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "223e61ad",
+ "metadata": {},
+ "source": [
+ "## 2. Build a Medical Knowledge Graph\n",
+ "\n",
+ "We construct a toy knowledge graph loosely modeled on UMLS relationships between drugs, conditions, procedures, and drug classes.\n",
+ "\n",
+ "For real use, you would load a KG from CSV/TSV:\n",
+ "```python\n",
+ "kg = KnowledgeGraph(triples=\"data/umls_triples.csv\")\n",
+ "```"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "594b5e36",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "triples = [\n",
+ " # Drug-condition relationships\n",
+ " (\"aspirin\", \"treats\", \"headache\"),\n",
+ " (\"aspirin\", \"treats\", \"fever\"),\n",
+ " (\"ibuprofen\", \"treats\", \"headache\"),\n",
+ " (\"ibuprofen\", \"treats\", \"inflammation\"),\n",
+ " (\"metformin\", \"treats\", \"diabetes\"),\n",
+ " (\"insulin\", \"treats\", \"diabetes\"),\n",
+ " (\"lisinopril\", \"treats\", \"hypertension\"),\n",
+ " (\"amlodipine\", \"treats\", \"hypertension\"),\n",
+ " (\"atorvastatin\", \"treats\", \"hyperlipidemia\"),\n",
+ " (\"warfarin\", \"treats\", \"thrombosis\"),\n",
+ " # Condition hierarchy\n",
+ " (\"headache\", \"symptom_of\", \"migraine\"),\n",
+ " (\"fever\", \"symptom_of\", \"infection\"),\n",
+ " (\"diabetes\", \"risk_factor_for\", \"heart_disease\"),\n",
+ " (\"hypertension\", \"risk_factor_for\", \"heart_disease\"),\n",
+ " (\"hyperlipidemia\", \"risk_factor_for\", \"heart_disease\"),\n",
+ " (\"thrombosis\", \"complication_of\", \"heart_disease\"),\n",
+ " (\"inflammation\", \"associated_with\", \"infection\"),\n",
+ " # Drug classes\n",
+ " (\"aspirin\", \"is_a\", \"nsaid\"),\n",
+ " (\"ibuprofen\", \"is_a\", \"nsaid\"),\n",
+ " (\"metformin\", \"is_a\", \"antidiabetic\"),\n",
+ " (\"insulin\", \"is_a\", \"antidiabetic\"),\n",
+ " (\"lisinopril\", \"is_a\", \"ace_inhibitor\"),\n",
+ " (\"amlodipine\", \"is_a\", \"calcium_blocker\"),\n",
+ " (\"atorvastatin\", \"is_a\", \"statin\"),\n",
+ " # Procedure relationships\n",
+ " (\"ecg\", \"diagnoses\", \"heart_disease\"),\n",
+ " (\"blood_test\", \"diagnoses\", \"diabetes\"),\n",
+ " (\"blood_test\", \"diagnoses\", \"hyperlipidemia\"),\n",
+ " (\"ct_scan\", \"diagnoses\", \"thrombosis\"),\n",
+ " (\"xray\", \"diagnoses\", \"inflammation\"),\n",
+ "]\n",
+ "\n",
+ "kg = KnowledgeGraph(triples=triples)\n",
+ "print(f\"Entities: {kg.num_entities}\")\n",
+ "print(f\"Relations: {kg.num_relations}\")\n",
+ "print(f\"Triples: {kg.num_triples}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "d90ec218",
+ "metadata": {},
+ "source": [
+ "## 3. Create Synthetic Patient Samples\n",
+ "\n",
+ "We generate 100 synthetic patients across three archetypes:\n",
+ "- **Cardiac** (40 patients): Higher mortality risk\n",
+ "- **Diabetic** (30 patients): Moderate mortality risk\n",
+ "- **Mild** (30 patients): Low mortality risk"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "88e07010",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "CARDIAC_CONDITIONS = [\n",
+ " \"hypertension\", \"hyperlipidemia\", \"heart_disease\",\n",
+ "]\n",
+ "CARDIAC_PROCEDURES = [\"ecg\", \"blood_test\"]\n",
+ "CARDIAC_DRUGS = [\"lisinopril\", \"atorvastatin\", \"aspirin\"]\n",
+ "\n",
+ "DIABETIC_CONDITIONS = [\"diabetes\", \"hypertension\"]\n",
+ "DIABETIC_PROCEDURES = [\"blood_test\"]\n",
+ "DIABETIC_DRUGS = [\"metformin\", \"insulin\", \"lisinopril\"]\n",
+ "\n",
+ "MILD_CONDITIONS = [\"headache\", \"fever\"]\n",
+ "MILD_PROCEDURES = [\"xray\"]\n",
+ "MILD_DRUGS = [\"aspirin\", \"ibuprofen\"]\n",
+ "\n",
+ "\n",
+ "def make_patient(pid, archetype, label):\n",
+ " if archetype == \"cardiac\":\n",
+ " conds = random.sample(\n",
+ " CARDIAC_CONDITIONS, k=random.randint(2, 3)\n",
+ " )\n",
+ " procs = random.sample(\n",
+ " CARDIAC_PROCEDURES, k=random.randint(1, 2)\n",
+ " )\n",
+ " drugs = random.sample(\n",
+ " CARDIAC_DRUGS, k=random.randint(2, 3)\n",
+ " )\n",
+ " elif archetype == \"diabetic\":\n",
+ " conds = random.sample(\n",
+ " DIABETIC_CONDITIONS, k=random.randint(1, 2)\n",
+ " )\n",
+ " procs = random.sample(DIABETIC_PROCEDURES, k=1)\n",
+ " drugs = random.sample(\n",
+ " DIABETIC_DRUGS, k=random.randint(2, 3)\n",
+ " )\n",
+ " else:\n",
+ " conds = random.sample(\n",
+ " MILD_CONDITIONS, k=random.randint(1, 2)\n",
+ " )\n",
+ " procs = random.sample(MILD_PROCEDURES, k=1)\n",
+ " drugs = random.sample(\n",
+ " MILD_DRUGS, k=random.randint(1, 2)\n",
+ " )\n",
+ " return {\n",
+ " \"patient_id\": f\"p{pid}\",\n",
+ " \"visit_id\": \"v0\",\n",
+ " \"conditions\": conds,\n",
+ " \"procedures\": procs,\n",
+ " \"drugs\": drugs,\n",
+ " \"mortality\": label,\n",
+ " }\n",
+ "\n",
+ "\n",
+ "samples = []\n",
+ "for i in range(40):\n",
+ " label = 1 if random.random() < 0.6 else 0\n",
+ " samples.append(make_patient(i, \"cardiac\", label))\n",
+ "for i in range(40, 70):\n",
+ " label = 1 if random.random() < 0.3 else 0\n",
+ " samples.append(make_patient(i, \"diabetic\", label))\n",
+ "for i in range(70, 100):\n",
+ " label = 1 if random.random() < 0.1 else 0\n",
+ " samples.append(make_patient(i, \"mild\", label))\n",
+ "\n",
+ "random.shuffle(samples)\n",
+ "\n",
+ "pos = sum(s[\"mortality\"] for s in samples)\n",
+ "neg = len(samples) - pos\n",
+ "print(f\"Patients: {len(samples)} ({pos} pos, {neg} neg)\")\n",
+ "print(f\"Sample: {samples[0]}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "43cae6f8",
+ "metadata": {},
+ "source": [
+ "## 4. Create PyHealth Dataset with Graph Processor\n",
+ "\n",
+ "The key step: we configure `input_schema` to use the `\"graph\"` processor. This tells PyHealth to run each patient's codes through `GraphProcessor`, which extracts k-hop subgraphs from the KG and returns PyG `Data` objects."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "8d3b52e7",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "input_schema = {\n",
+ " \"conditions\": (\"graph\", {\n",
+ " \"knowledge_graph\": kg, \"num_hops\": 2,\n",
+ " }),\n",
+ " \"procedures\": (\"graph\", {\n",
+ " \"knowledge_graph\": kg, \"num_hops\": 2,\n",
+ " }),\n",
+ " \"drugs\": (\"graph\", {\n",
+ " \"knowledge_graph\": kg, \"num_hops\": 2,\n",
+ " }),\n",
+ "}\n",
+ "output_schema = {\"mortality\": \"binary\"}\n",
+ "\n",
+ "dataset = create_sample_dataset(\n",
+ " samples=samples,\n",
+ " input_schema=input_schema,\n",
+ " output_schema=output_schema,\n",
+ " dataset_name=\"graphcare_synthetic\",\n",
+ ")\n",
+ "\n",
+ "train_ds, val_ds, test_ds = split_by_sample(\n",
+ " dataset, ratios=[0.7, 0.1, 0.2], seed=SEED\n",
+ ")\n",
+ "print(f\"Train/Val/Test: \"\n",
+ " f\"{len(train_ds)}/{len(val_ds)}/{len(test_ds)}\")\n",
+ "\n",
+ "BATCH_SIZE = 16\n",
+ "train_loader = get_dataloader(\n",
+ " train_ds, batch_size=BATCH_SIZE, shuffle=True\n",
+ ")\n",
+ "val_loader = get_dataloader(\n",
+ " val_ds, batch_size=BATCH_SIZE, shuffle=False\n",
+ ")\n",
+ "test_loader = get_dataloader(\n",
+ " test_ds, batch_size=BATCH_SIZE, shuffle=False\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "81d79d66",
+ "metadata": {},
+ "source": [
+ "## 5. Inspect Batch Structure\n",
+ "\n",
+ "Each feature key produces a PyG `Batch` object containing all patient subgraphs in the mini-batch."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "f9c6f219",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "batch = next(iter(train_loader))\n",
+ "print(f\"Batch keys: {list(batch.keys())}\")\n",
+ "for key in [\"conditions\", \"procedures\", \"drugs\"]:\n",
+ " b = batch[key]\n",
+ " print(\n",
+ " f\" {key}: {b.num_nodes} nodes, \"\n",
+ " f\"{b.num_edges} edges, \"\n",
+ " f\"{b.num_graphs} graphs\"\n",
+ " )\n",
+ "print(f\" mortality: {batch['mortality'].shape}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "4c10dae3",
+ "metadata": {},
+ "source": [
+ "## 6. Train GraphCare (BAT Backbone)\n",
+ "\n",
+ "The default configuration uses the Bi-Attention augmented GNN (BAT) from the paper with `joint` patient representation (graph-level + node-level pooling concatenated)."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "b52aeb0d",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "model = GraphCare(\n",
+ " dataset=dataset,\n",
+ " knowledge_graph=kg,\n",
+ " hidden_dim=64,\n",
+ " num_layers=2,\n",
+ " gnn_type=\"bat\",\n",
+ " patient_mode=\"joint\",\n",
+ " dropout=0.3,\n",
+ ")\n",
+ "\n",
+ "total_params = sum(p.numel() for p in model.parameters())\n",
+ "print(f\"Feature keys: {model.feature_keys}\")\n",
+ "print(f\"Label key: {model.label_key}\")\n",
+ "print(f\"Parameters: {total_params:,}\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "1c09a19f",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "trainer = Trainer(\n",
+ " model=model,\n",
+ " metrics=[\"roc_auc\", \"pr_auc\"],\n",
+ " device=DEVICE,\n",
+ " enable_logging=False,\n",
+ ")\n",
+ "\n",
+ "trainer.train(\n",
+ " train_dataloader=train_loader,\n",
+ " val_dataloader=val_loader,\n",
+ " epochs=10,\n",
+ " optimizer_params={\"lr\": 1e-3},\n",
+ " monitor=\"roc_auc\",\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "417d6a34",
+ "metadata": {},
+ "source": [
+ "## 7. Evaluate on Test Set"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "89a5e110",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "results = trainer.evaluate(test_loader)\n",
+ "for metric, value in results.items():\n",
+ " print(f\" {metric}: {value:.4f}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "d2acca76",
+ "metadata": {},
+ "source": [
+ "## 8. Compare GNN Backbones\n",
+ "\n",
+ "GraphCare supports three GNN backbones:\n",
+ "- **BAT**: Bi-Attention augmented GNN (from the paper)\n",
+ "- **GAT**: Graph Attention Network (Veličković et al., 2018)\n",
+ "- **GIN**: Graph Isomorphism Network (Xu et al., 2019)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "40f808f6",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "backbone_results = {}\n",
+ "\n",
+ "for gnn_type in [\"bat\", \"gat\", \"gin\"]:\n",
+ " torch.manual_seed(SEED)\n",
+ " m = GraphCare(\n",
+ " dataset=dataset,\n",
+ " knowledge_graph=kg,\n",
+ " hidden_dim=64,\n",
+ " num_layers=2,\n",
+ " gnn_type=gnn_type,\n",
+ " heads=4,\n",
+ " patient_mode=\"joint\",\n",
+ " dropout=0.3,\n",
+ " )\n",
+ " t = Trainer(\n",
+ " model=m,\n",
+ " metrics=[\"roc_auc\", \"pr_auc\"],\n",
+ " device=DEVICE,\n",
+ " enable_logging=False,\n",
+ " )\n",
+ " t.train(\n",
+ " train_dataloader=train_loader,\n",
+ " val_dataloader=val_loader,\n",
+ " epochs=10,\n",
+ " optimizer_params={\"lr\": 1e-3},\n",
+ " monitor=\"roc_auc\",\n",
+ " )\n",
+ " res = t.evaluate(test_loader)\n",
+ " backbone_results[gnn_type] = res\n",
+ "\n",
+ "print(f\"{'Backbone':<10} {'ROC-AUC':>10} {'PR-AUC':>10}\")\n",
+ "print(\"-\" * 32)\n",
+ "for name, res in backbone_results.items():\n",
+ " roc = res.get(\"roc_auc\", 0)\n",
+ " pr = res.get(\"pr_auc\", 0)\n",
+ " print(f\"{name.upper():<10} {roc:>10.4f} {pr:>10.4f}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "d7e791e1",
+ "metadata": {},
+ "source": [
+ "## 9. Compare Patient Representation Modes\n",
+ "\n",
+ "- **joint**: Concatenates graph-level (mean pool) and node-level (attention-weighted) representations\n",
+ "- **graph**: Graph-level mean pooling only\n",
+ "- **node**: Attention-weighted node pooling only"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "b0ec24df",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "mode_results = {}\n",
+ "\n",
+ "for mode in [\"joint\", \"graph\", \"node\"]:\n",
+ " torch.manual_seed(SEED)\n",
+ " m = GraphCare(\n",
+ " dataset=dataset,\n",
+ " knowledge_graph=kg,\n",
+ " hidden_dim=64,\n",
+ " num_layers=2,\n",
+ " gnn_type=\"bat\",\n",
+ " patient_mode=mode,\n",
+ " dropout=0.3,\n",
+ " )\n",
+ " t = Trainer(\n",
+ " model=m,\n",
+ " metrics=[\"roc_auc\", \"pr_auc\"],\n",
+ " device=DEVICE,\n",
+ " enable_logging=False,\n",
+ " )\n",
+ " t.train(\n",
+ " train_dataloader=train_loader,\n",
+ " val_dataloader=val_loader,\n",
+ " epochs=10,\n",
+ " optimizer_params={\"lr\": 1e-3},\n",
+ " monitor=\"roc_auc\",\n",
+ " )\n",
+ " res = t.evaluate(test_loader)\n",
+ " mode_results[mode] = res\n",
+ "\n",
+ "print(f\"{'Mode':<10} {'ROC-AUC':>10} {'PR-AUC':>10}\")\n",
+ "print(\"-\" * 32)\n",
+ "for name, res in mode_results.items():\n",
+ " roc = res.get(\"roc_auc\", 0)\n",
+ " pr = res.get(\"pr_auc\", 0)\n",
+ " print(f\"{name:<10} {roc:>10.4f} {pr:>10.4f}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "01bda898",
+ "metadata": {},
+ "source": [
+ "## 10. Using with Real MIMIC Data\n",
+ "\n",
+ "For real clinical benchmarks, use an existing PyHealth task with graph-based input schema:\n",
+ "\n",
+ "```python\n",
+ "from pyhealth.datasets import MIMIC3Dataset\n",
+ "from pyhealth.tasks import MortalityPredictionMIMIC3\n",
+ "from pyhealth.graph import KnowledgeGraph\n",
+ "from pyhealth.models.graphcare import GraphCare\n",
+ "\n",
+ "# Load KG (user-provided, e.g. UMLS or LLM-generated)\n",
+ "kg = KnowledgeGraph(triples=\"data/umls_triples.csv\")\n",
+ "\n",
+ "# Load dataset\n",
+ "dataset = MIMIC3Dataset(\n",
+ " root=\"/path/to/mimic-iii\",\n",
+ " tables=[\n",
+ " \"diagnoses_icd\",\n",
+ " \"procedures_icd\",\n",
+ " \"prescriptions\",\n",
+ " ],\n",
+ ")\n",
+ "\n",
+ "# Override schema to use graph processor\n",
+ "task = MortalityPredictionMIMIC3()\n",
+ "task.input_schema = {\n",
+ " \"conditions\": (\"graph\", {\n",
+ " \"knowledge_graph\": kg, \"num_hops\": 2,\n",
+ " }),\n",
+ " \"procedures\": (\"graph\", {\n",
+ " \"knowledge_graph\": kg, \"num_hops\": 2,\n",
+ " }),\n",
+ " \"drugs\": (\"graph\", {\n",
+ " \"knowledge_graph\": kg, \"num_hops\": 2,\n",
+ " }),\n",
+ "}\n",
+ "\n",
+ "samples = dataset.set_task(task)\n",
+ "model = GraphCare(\n",
+ " dataset=samples,\n",
+ " knowledge_graph=kg,\n",
+ " hidden_dim=128,\n",
+ " num_layers=3,\n",
+ ")\n",
+ "```\n",
+ "\n",
+ "See the [GraphCare paper](https://openreview.net/forum?id=tVTN7Zs0ml) and [original repo](https://github.com/pat-jj/GraphCare) for KG generation details."
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "base",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.12.3"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/examples/halo_mimic3.py b/examples/halo_mimic3.py
new file mode 100644
index 000000000..3ea0a71be
--- /dev/null
+++ b/examples/halo_mimic3.py
@@ -0,0 +1,132 @@
+"""Example: train HALO on MIMIC-III and generate synthetic patients.
+
+This example demonstrates:
+1. Loading MIMIC-III data
+2. Applying the EHRGenerationMIMIC3 task (per-visit ICD-9 code sequences)
+3. Creating a SampleDataset with a NestedSequenceProcessor
+4. Training the HALO generator with its custom training loop
+5. Generating synthetic patients
+6. Evaluating the synthetic data with the generative metrics suite
+"""
+
+import pandas as pd
+
+from pyhealth.datasets import MIMIC3Dataset, split_by_patient
+from pyhealth.metrics.generative import evaluate_synthetic_ehr
+from pyhealth.models import HALO
+from pyhealth.tasks import EHRGenerationMIMIC3
+
+if __name__ == "__main__":
+ # STEP 1: Load MIMIC-III base dataset
+ base_dataset = MIMIC3Dataset(
+ root="/srv/local/data/MIMIC-III/mimic-iii-clinical-database-1.4",
+ tables=["diagnoses_icd"],
+ dev=True,
+ )
+
+ # STEP 2: Apply the EHR generation task (unconditional, no labels).
+ # This task is shared by all generators in pyhealth.models.generators.
+ sample_dataset = base_dataset.set_task(EHRGenerationMIMIC3())
+ print(f"Total samples: {len(sample_dataset)}")
+ print(f"Input schema: {sample_dataset.input_schema}")
+ print(f"Output schema: {sample_dataset.output_schema}")
+
+ sample = sample_dataset[0]
+ print("\nSample structure:")
+ print(f" Patient ID: {sample['patient_id']}")
+ print(f" Visits tensor shape: {tuple(sample['visits'].shape)}")
+
+ # STEP 3: Split dataset by patient
+ train_dataset, val_dataset, test_dataset = split_by_patient(
+ sample_dataset, [0.8, 0.1, 0.1]
+ )
+
+ # STEP 4: Initialize HALO (small config for the dev subset)
+ model = HALO(
+ dataset=sample_dataset,
+ embed_dim=128,
+ n_heads=4,
+ n_layers=4,
+ n_ctx=48,
+ batch_size=16,
+ epochs=5,
+ lr=1e-4,
+ save_dir="./halo_save",
+ )
+ num_params = sum(p.numel() for p in model.parameters())
+ print(f"\nModel initialized with {num_params} parameters")
+
+ # STEP 5: Train with HALO's custom loop (saves best checkpoint to save_dir)
+ model.train_model(train_dataset, val_dataset=val_dataset)
+
+ # STEP 6: Generate synthetic patients (one per real training patient).
+ synthetic = model.generate(num_samples=len(train_dataset), random_sampling=True)
+ print("\nGenerated synthetic patients (first 3):")
+ for patient in synthetic[:3]:
+ print(f" {patient['patient_id']}: {len(patient['visits'])} visits")
+ print(f" {patient['visits']}")
+
+ # STEP 7: Evaluate the synthetic data with the generative metrics suite.
+ # evaluate_synthetic_ehr (and every metric it calls) expects flat /
+ # long-format dataframes -- ONE ROW PER (patient, visit, code) event --
+ # with four columns:
+ # - id patient identifier (any hashable; str here)
+ # - time visit index / timestep (sortable; int here)
+ # - visit_codes a SINGLE medical code (str or int; one per row,
+ # NOT a list/array -- a
+ # visit with k codes spans
+ # k rows)
+ # - labels per-patient binary label (0/1, int)
+ # train_df, test_df and syn_df below all share this exact schema. `labels`
+ # is a placeholder here: privacy metrics ignore it and the utility metric
+ # overwrites it with the next-visit prediction target.
+ index_to_code = {
+ v: k for k, v in sample_dataset.input_processors["visits"].code_vocab.items()
+ }
+
+ def real_subset_to_records(subset):
+ for sample in subset:
+ pid = str(sample["patient_id"])
+ visits_tensor = sample["visits"]
+ for t, visit in enumerate(visits_tensor.tolist()):
+ for idx in visit:
+ code = index_to_code.get(int(idx))
+ if code in (None, "", ""):
+ continue
+ yield {"id": pid, "time": t, "visit_codes": code, "labels": 0}
+
+ def synthetic_to_records(patients):
+ for p in patients:
+ pid = str(p["patient_id"])
+ for t, visit in enumerate(p["visits"]):
+ for code in visit:
+ yield {"id": pid, "time": t, "visit_codes": code, "labels": 0}
+
+ schema = {"visit_codes": str, "labels": int, "time": int, "id": str}
+ train_df = pd.DataFrame(real_subset_to_records(train_dataset)).astype(schema)
+ test_df = pd.DataFrame(real_subset_to_records(test_dataset)).astype(schema)
+ syn_df = pd.DataFrame(synthetic_to_records(synthetic)).astype(schema)
+ print(
+ f"\nEval rows -- train: {len(train_df)}, test: {len(test_df)}, "
+ f"synthetic: {len(syn_df)}"
+ )
+ # Show the flat schema: one row per (patient, visit, code) event.
+ print("\ntrain_df schema (one row per (patient, visit, code)):")
+ print(train_df.head())
+
+ # sample_size / n_bootstraps / n_runs are kept small for the dev subset;
+ # raise them when running on the full MIMIC-III cohort.
+ results = evaluate_synthetic_ehr(
+ train_ehr=train_df,
+ test_ehr=test_df,
+ syn_ehr=syn_df,
+ sample_size=min(30, len(train_dataset), len(test_dataset)),
+ mode="lstm",
+ metrics="all",
+ lstm_params={"embed_dim": 16, "hidden_dim": 16, "batch_size": 16, "epochs": 3},
+ n_bootstraps=5,
+ n_runs=3,
+ )
+ print("\nGenerative metrics (mean +/- std):")
+ for name, (mean, std) in results.items():
+ print(f" {name:30s} {mean:.4f} +/- {std:.4f}")
diff --git a/examples/interpretability/custom_sample_filter.py b/examples/interpretability/custom_sample_filter.py
new file mode 100644
index 000000000..da59546c5
--- /dev/null
+++ b/examples/interpretability/custom_sample_filter.py
@@ -0,0 +1,189 @@
+"""Evaluate all interpretability methods on StageNet + MIMIC-IV dataset using comprehensiveness
+and sufficiency metrics.
+
+This example demonstrates:
+1. Loading a pre-trained StageNet model with processors and MIMIC-IV dataset
+2. Computing attributions with various interpretability methods
+3. Evaluating attribution faithfulness with Comprehensiveness & Sufficiency for each method
+4. Presenting results in a summary table
+"""
+
+import datetime
+import argparse
+
+import torch
+from pyhealth.datasets import MIMIC4Dataset, get_dataloader, split_by_patient
+from pyhealth.interpret.methods import *
+from pyhealth.metrics.interpretability import evaluate_attribution
+from pyhealth.metrics.interpretability.utils import SampleClass
+from pyhealth.models import Transformer
+from pyhealth.tasks import MortalityPredictionStageNetMIMIC4
+from pyhealth.trainer import Trainer
+from pyhealth.datasets.utils import load_processors
+from pathlib import Path
+import pandas as pd
+
+# python -u examples/interpretability/custom_sample_filter.py --pos_threshold 0.5 --neg_threshold 0.1 --device cuda:2
+def main():
+ parser = argparse.ArgumentParser(
+ description="Comma separated list of interpretability methods to evaluate"
+ )
+ parser.add_argument(
+ "--pos_threshold",
+ type=float,
+ default=None,
+ help="Positive threshold for interpretability evaluation (default: 0.5).",
+ )
+ parser.add_argument(
+ "--neg_threshold",
+ type=float,
+ default=None,
+ help="Negative threshold for interpretability evaluation (default: 0.5).",
+ )
+ parser.add_argument(
+ "--device",
+ type=str,
+ default="cuda:0",
+ help="Device to use for evaluation (default: cuda:0)",
+ )
+ args = parser.parse_args()
+ """Main execution function."""
+ print("=" * 70)
+ print("Interpretability Metrics Example: Transformer + MIMIC-IV")
+ print("=" * 70)
+
+ now = datetime.datetime.now()
+ print(f"Start Time: {now.strftime('%Y-%m-%d %H:%M:%S')}")
+
+ # Set path
+ CACHE_DIR = Path("/home/yongdaf2/interpret/cache/mp_mimic4")
+ CKPTS_DIR = Path("/shared/eng/pyhealth_dka/ckpts/mp_transformer_mimic4")
+ OUTPUT_DIR = Path("/home/yongdaf2/interpret/output/mp_transformer_mimic4")
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
+ CKPTS_DIR.mkdir(parents=True, exist_ok=True)
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
+ print(f"\nUsing cache dir: {CACHE_DIR}")
+ print(f"Using checkpoints dir: {CKPTS_DIR}")
+ print(f"Using output dir: {OUTPUT_DIR}")
+
+ # Set device
+ device = args.device
+ print(f"\nUsing device: {device}")
+
+ # Load MIMIC-IV dataset
+ print("\n Loading MIMIC-IV dataset...")
+ base_dataset = MIMIC4Dataset(
+ ehr_root="/srv/local/data/physionet.org/files/mimiciv/2.2/",
+ ehr_tables=[
+ "patients",
+ "admissions",
+ "diagnoses_icd",
+ "procedures_icd",
+ "labevents",
+ ],
+ cache_dir=str(CACHE_DIR),
+ num_workers=16,
+ )
+
+ # Apply mortality prediction task
+ if not (CKPTS_DIR / "input_processors.pkl").exists():
+ raise FileNotFoundError(f"Input processors not found in {CKPTS_DIR}. ")
+ if not (CKPTS_DIR / "output_processors.pkl").exists():
+ raise FileNotFoundError(f"Output processors not found in {CKPTS_DIR}. ")
+ input_processors, output_processors = load_processors(str(CKPTS_DIR))
+ print("✓ Loaded input and output processors from checkpoint directory.")
+
+ sample_dataset = base_dataset.set_task(
+ MortalityPredictionStageNetMIMIC4(),
+ num_workers=16,
+ input_processors=input_processors,
+ output_processors=output_processors,
+ )
+ print(f"✓ Loaded {len(sample_dataset)} samples")
+
+ # Split dataset and get test loader
+ _, _, test_dataset = split_by_patient(sample_dataset, [0.9, 0.09, 0.01], seed=233)
+ test_loader = get_dataloader(test_dataset, batch_size=16, shuffle=False)
+ print(f"✓ Test set: {len(test_dataset)} samples")
+
+ # Initialize and load pre-trained model
+ print("\n Loading pre-trained Transformer model...")
+ model = Transformer(
+ dataset=sample_dataset,
+ embedding_dim=128,
+ heads=4,
+ dropout=0.3,
+ num_layers=3,
+ )
+
+ trainer = Trainer(model=model, device=device)
+ trainer.load_ckpt(str(CKPTS_DIR / "best.ckpt"))
+ model = model.to(device)
+ model.eval()
+ print(f"✓ Loaded checkpoint: {CKPTS_DIR / 'best.ckpt'}")
+ print(f"✓ Model moved to {device}")
+
+ pos_threshold = args.pos_threshold
+ neg_threshold = args.neg_threshold
+ def sample_filter_fn(
+ y_probs: torch.Tensor,
+ classifier_type: str,
+ ) -> torch.Tensor:
+ """
+ Custom sample filter function that classifies samples based on
+ positive and negative probability thresholds.
+
+ negative samples: 0 < y_probs < neg_threshold
+ ignored samples: neg_threshold <= y_probs < pos_threshold
+ positive samples: y_probs >= pos_threshold
+ """
+ nonlocal pos_threshold, neg_threshold
+ batch_size = y_probs.shape[0]
+ result = torch.full(
+ (batch_size,),
+ SampleClass.POSITIVE,
+ dtype=torch.long,
+ device=y_probs.device,
+ )
+ if classifier_type in ("binary", "multilabel"):
+ if pos_threshold is not None:
+ result[y_probs < pos_threshold] = SampleClass.IGNORE
+ if neg_threshold is not None:
+ result[y_probs < neg_threshold] = SampleClass.NEGATIVE
+ return result
+
+ interpreter = IntegratedGradients(model, use_embeddings=True)
+ print(f"\nEvaluating using Integrated Gradients...")
+
+ # Option 1: Functional API (simple one-off evaluation)
+ print("\nEvaluating with Functional API on full dataset...")
+ print("Using: evaluate_attribution(model, dataloader, method, ...)")
+
+ results_functional = evaluate_attribution(
+ model,
+ test_loader,
+ interpreter,
+ metrics=["comprehensiveness", "sufficiency"],
+ percentages=[25, 50, 99],
+ sample_filter=sample_filter_fn,
+ )
+
+ print("\n" + "=" * 70)
+ print("Dataset-Wide Results (Functional API)")
+ print("=" * 70)
+ comp = results_functional["comprehensiveness"]
+ suff = results_functional["sufficiency"]
+ print(f"\nComprehensiveness: {comp:.4f}")
+ print(f"Sufficiency: {suff:.4f}")
+
+ print("")
+ print("=" * 70)
+ print("Summary of Results for All Methods")
+ print({"Method": "Integrated Gradients", "Comprehensiveness": comp, "Sufficiency": suff})
+
+ end = datetime.datetime.now()
+ print(f"End Time: {end.strftime('%Y-%m-%d %H:%M:%S')}")
+ print(f"Total Duration: {end - now}")
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/mimic4_califorest.py b/examples/mimic4_califorest.py
new file mode 100644
index 000000000..01d26de2d
--- /dev/null
+++ b/examples/mimic4_califorest.py
@@ -0,0 +1,190 @@
+from __future__ import annotations
+
+import os
+
+import numpy as np
+import torch
+from sklearn.ensemble import RandomForestClassifier
+from sklearn.metrics import brier_score_loss, roc_auc_score
+
+from pyhealth.datasets import (
+ MIMIC4EHRDataset,
+ create_sample_dataset,
+ get_dataloader,
+)
+from pyhealth.models import CaliForest
+from pyhealth.tasks import InHospitalMortalityMIMIC4
+
+
+# Set your MIMIC-IV dataset path via environment variable before running:
+# export MIMIC4_ROOT=/your/path/to/mimiciv/3.1
+ROOT = os.getenv("MIMIC4_ROOT")
+
+
+def evaluate(y_true: np.ndarray, y_prob: np.ndarray) -> dict[str, float]:
+ """Compute AUROC and Brier score."""
+ y_true = np.asarray(y_true).reshape(-1)
+ y_prob = np.asarray(y_prob).reshape(-1)
+ return {
+ "auroc": float(roc_auc_score(y_true, y_prob)),
+ "brier": float(brier_score_loss(y_true, y_prob)),
+ }
+
+
+def run_califorest(
+ X_train: np.ndarray,
+ y_train: np.ndarray,
+ X_test: np.ndarray,
+ y_test: np.ndarray,
+ calibration: str,
+) -> dict[str, float]:
+ """Train and evaluate CaliForest on tabularized features."""
+ train_samples = []
+ for i in range(len(X_train)):
+ train_samples.append(
+ {
+ "patient_id": f"train-{i}",
+ "visit_id": f"train-{i}",
+ "features": X_train[i].tolist(),
+ "label": int(y_train[i]),
+ }
+ )
+
+ test_samples = []
+ for i in range(len(X_test)):
+ test_samples.append(
+ {
+ "patient_id": f"test-{i}",
+ "visit_id": f"test-{i}",
+ "features": X_test[i].tolist(),
+ "label": int(y_test[i]),
+ }
+ )
+
+ train_dataset = create_sample_dataset(
+ samples=train_samples,
+ input_schema={"features": "tensor"},
+ output_schema={"label": "binary"},
+ dataset_name=f"mimic4_train_tabular_{calibration}",
+ )
+ test_dataset = create_sample_dataset(
+ samples=test_samples,
+ input_schema={"features": "tensor"},
+ output_schema={"label": "binary"},
+ dataset_name=f"mimic4_test_tabular_{calibration}",
+ )
+
+ train_loader = get_dataloader(
+ train_dataset, batch_size=len(train_dataset), shuffle=False
+ )
+ test_loader = get_dataloader(
+ test_dataset, batch_size=len(test_dataset), shuffle=False
+ )
+
+ test_batch = next(iter(test_loader))
+
+ model = CaliForest(
+ dataset=train_dataset,
+ n_estimators=100,
+ calibration=calibration,
+ random_state=42,
+ )
+ model.fit(train_loader)
+
+ with torch.no_grad():
+ ret = model(**test_batch)
+
+ cali_probs = ret["y_prob"].detach().cpu().numpy().reshape(-1)
+ return evaluate(y_test, cali_probs)
+
+
+def main():
+ if not ROOT:
+ raise ValueError(
+ "MIMIC4_ROOT is not set. Example:\n"
+ "export MIMIC4_ROOT=/your/path/to/mimiciv/3.1"
+ )
+
+ print("=" * 80)
+ print("Loading MIMIC-IV EHR dataset")
+ print("=" * 80)
+
+ dataset = MIMIC4EHRDataset(
+ root=ROOT,
+ tables=["diagnoses_icd", "procedures_icd", "labevents"],
+ )
+
+ task = InHospitalMortalityMIMIC4()
+ sample_dataset = dataset.set_task(task)
+
+ print(f"Total samples: {len(sample_dataset)}")
+
+ subset_size = 2000
+ raw_subset_samples = [sample_dataset[i] for i in range(subset_size)]
+
+ clean_subset_samples = []
+ for sample in raw_subset_samples:
+ clean_subset_samples.append(
+ {
+ "patient_id": str(sample["patient_id"]),
+ "visit_id": str(sample["admission_id"]),
+ "labs": sample["labs"].tolist(),
+ "mortality": int(sample["mortality"].item()),
+ }
+ )
+
+ subset_dataset = create_sample_dataset(
+ samples=clean_subset_samples,
+ input_schema={"labs": "tensor"},
+ output_schema={"mortality": "binary"},
+ dataset_name="mimic4_mortality_subset",
+ )
+
+ loader = get_dataloader(subset_dataset, batch_size=subset_size, shuffle=False)
+ batch = next(iter(loader))
+
+ X = batch["labs"].detach().cpu().numpy()
+ y = batch["mortality"].detach().cpu().numpy().reshape(-1)
+
+ X = X.reshape(X.shape[0], -1)
+
+ print("Flattened feature matrix:", X.shape)
+ print("Labels:", y.shape)
+
+ split = int(0.8 * len(X))
+ X_train, X_test = X[:split], X[split:]
+ y_train, y_test = y[:split], y[split:]
+
+ print("=" * 80)
+ print("Baseline Random Forest")
+ print("=" * 80)
+
+ rf = RandomForestClassifier(
+ n_estimators=100,
+ random_state=42,
+ bootstrap=True,
+ )
+ rf.fit(X_train, y_train)
+ rf_probs = rf.predict_proba(X_test)[:, 1]
+ rf_metrics = evaluate(y_test, rf_probs)
+ print("RF metrics:", rf_metrics)
+
+ print("=" * 80)
+ print("CaliForest (isotonic calibration)")
+ print("=" * 80)
+ isotonic_metrics = run_califorest(
+ X_train, y_train, X_test, y_test, calibration="isotonic"
+ )
+ print("CaliForest isotonic metrics:", isotonic_metrics)
+
+ print("=" * 80)
+ print("CaliForest (logistic calibration)")
+ print("=" * 80)
+ logistic_metrics = run_califorest(
+ X_train, y_train, X_test, y_test, calibration="logistic"
+ )
+ print("CaliForest logistic metrics:", logistic_metrics)
+
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/examples/mimic4fhir_mpf_ehrmamba.py b/examples/mimic4fhir_mpf_ehrmamba.py
new file mode 100644
index 000000000..33df598af
--- /dev/null
+++ b/examples/mimic4fhir_mpf_ehrmamba.py
@@ -0,0 +1,61 @@
+"""EHRMambaCEHR on the local MIMIC-IV FHIR demo.
+
+Barebones path: Dataset -> task -> model -> trainer -> evaluate.
+
+Runs against the bundled demo at
+``datasets/physionet.org/mimic-iv-fhir-demo/2.1.0/fhir`` and persists the
+flattened-table cache under ``datasets/.cache/pyhealth/fhir-demo`` so a
+second run hits the cache.
+
+ PYTHONPATH=. python examples/mimic4fhir_mpf_ehrmamba.py
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from pyhealth.datasets import MIMIC4FHIR, get_dataloader, split_by_patient
+from pyhealth.models import EHRMambaCEHR
+from pyhealth.tasks.mpf_clinical_prediction import MPFClinicalPredictionTask
+from pyhealth.trainer import Trainer
+
+# Absolute paths to the bundled PhysioNet MIMIC-IV-on-FHIR demo and its cache.
+DEMO_ROOT = Path(
+ "/home/johnwu3/projects/PyHealth_Branch_Testing/datasets/"
+ "physionet.org/mimic-iv-fhir-demo/2.1.0/fhir"
+)
+CACHE_DIR = Path(
+ "/home/johnwu3/projects/PyHealth_Branch_Testing/datasets/.cache/pyhealth/fhir-demo"
+)
+
+
+def main() -> None:
+ dataset = MIMIC4FHIR(root=str(DEMO_ROOT), cache_dir=str(CACHE_DIR))
+ sample_dataset = dataset.set_task(MPFClinicalPredictionTask(), num_workers=1)
+
+ train_ds, val_ds, test_ds = split_by_patient(sample_dataset, [0.7, 0.1, 0.2])
+ train_loader = get_dataloader(train_ds, batch_size=8, shuffle=True)
+ val_loader = get_dataloader(val_ds, batch_size=8, shuffle=False)
+ test_loader = get_dataloader(test_ds, batch_size=8, shuffle=False)
+
+ vocab_size = sample_dataset.input_processors["concept_ids"].vocab.vocab_size
+ model = EHRMambaCEHR(
+ dataset=sample_dataset,
+ vocab_size=vocab_size,
+ embedding_dim=32,
+ num_layers=2,
+ dropout=0.1,
+ )
+
+ trainer = Trainer(model=model, metrics=["roc_auc", "pr_auc"])
+ trainer.train(
+ train_dataloader=train_loader,
+ val_dataloader=val_loader,
+ epochs=2,
+ monitor="roc_auc",
+ )
+ print(trainer.evaluate(test_loader))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/mortality_prediction/mortality_mimic3_grasp.py b/examples/mortality_prediction/mortality_mimic3_grasp.py
index 011bd13fe..c2c5c0369 100644
--- a/examples/mortality_prediction/mortality_mimic3_grasp.py
+++ b/examples/mortality_prediction/mortality_mimic3_grasp.py
@@ -1,40 +1,36 @@
+import tempfile
+
from pyhealth.datasets import MIMIC3Dataset
from pyhealth.datasets import split_by_patient, get_dataloader
from pyhealth.models import GRASP
-from pyhealth.tasks import mortality_prediction_mimic3_fn
+from pyhealth.tasks import MortalityPredictionMIMIC3
from pyhealth.trainer import Trainer
if __name__ == "__main__":
# STEP 1: load data
base_dataset = MIMIC3Dataset(
- root="/srv/local/data/physionet.org/files/mimiciii/1.4",
+ root="https://storage.googleapis.com/pyhealth/Synthetic_MIMIC-III",
tables=["DIAGNOSES_ICD", "PROCEDURES_ICD", "PRESCRIPTIONS"],
- code_mapping={"ICD9CM": "CCSCM", "ICD9PROC": "CCSPROC", "NDC": "ATC"},
- dev=False,
- refresh_cache=False,
+ cache_dir=tempfile.TemporaryDirectory().name,
+ dev=True,
)
- base_dataset.stat()
+ base_dataset.stats()
# STEP 2: set task
- sample_dataset = base_dataset.set_task(mortality_prediction_mimic3_fn)
- sample_dataset.stat()
+ task = MortalityPredictionMIMIC3()
+ sample_dataset = base_dataset.set_task(task)
train_dataset, val_dataset, test_dataset = split_by_patient(
sample_dataset, [0.8, 0.1, 0.1]
)
- train_dataloader = get_dataloader(train_dataset, batch_size=256, shuffle=True)
- val_dataloader = get_dataloader(val_dataset, batch_size=256, shuffle=False)
- test_dataloader = get_dataloader(test_dataset, batch_size=256, shuffle=False)
+ train_dataloader = get_dataloader(train_dataset, batch_size=32, shuffle=True)
+ val_dataloader = get_dataloader(val_dataset, batch_size=32, shuffle=False)
+ test_dataloader = get_dataloader(test_dataset, batch_size=32, shuffle=False)
# STEP 3: define model
model = GRASP(
dataset=sample_dataset,
- feature_keys=["conditions", "procedures"],
- label_key="label",
- mode="binary",
- use_embedding=[True, True, True],
- embedding_dim=32,
- hidden_dim=32,
+ cluster_num=2,
)
# STEP 4: define trainer
@@ -42,7 +38,7 @@
trainer.train(
train_dataloader=train_dataloader,
val_dataloader=val_dataloader,
- epochs=5,
+ epochs=1,
monitor="roc_auc",
)
diff --git a/examples/mortality_prediction/mortality_mimic3_grasp_gru_code_mapping_cached.ipynb b/examples/mortality_prediction/mortality_mimic3_grasp_gru_code_mapping_cached.ipynb
new file mode 100644
index 000000000..700cb8ff0
--- /dev/null
+++ b/examples/mortality_prediction/mortality_mimic3_grasp_gru_code_mapping_cached.ipynb
@@ -0,0 +1,3258 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# GRASP: Mortality Prediction on MIMIC-III (With code_mapping)\n",
+ "\n",
+ "This notebook runs the GRASP model for mortality prediction **with** `code_mapping` enabled.\n",
+ "Raw codes are mapped to grouped vocabularies before building the embedding table:\n",
+ "- ICD9CM → CCSCM (diagnosis codes → CCS categories)\n",
+ "- ICD9PROC → CCSPROC (procedure codes → CCS categories)\n",
+ "- NDC → ATC (drug codes → ATC categories)\n",
+ "\n",
+ "**Paper**: Liantao Ma et al. \"GRASP: Generic Framework for Health Status Representation Learning Based on Incorporating Knowledge from Similar Patients.\" AAAI 2021.\n",
+ "\n",
+ "GRASP encodes patient sequences with a backbone (ConCare, GRU, or LSTM), clusters patients via k-means, refines cluster representations with a 2-layer GCN, and blends cluster-level knowledge back into individual patient representations via a learned gating mechanism.\n",
+ "\n",
+ "**Model:** GRASP (GRU backbone + GCN cluster refinement) \n",
+ "**Task:** In-hospital mortality prediction \n",
+ "**Dataset:** Synthetic MIMIC-III (`dev=False`)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Step 1: Load the MIMIC-III Dataset\n",
+ "\n",
+ "We load the MIMIC-III dataset using PyHealth's `MIMIC3Dataset` class. We use the synthetic dataset hosted on GCS, which requires no credentials.\n",
+ "\n",
+ "- `root`: URL to the synthetic MIMIC-III data\n",
+ "- `tables`: Clinical tables to load (diagnoses, procedures, prescriptions)\n",
+ "- `dev`: Set to `False` for the full dataset"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "metadata": {
+ "scrolled": true
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\u001b[33mWARNING: Ignoring invalid distribution ~orch (/home/lolowo2/.local/lib/python3.13/site-packages)\u001b[0m\u001b[33m\n",
+ "\u001b[0m\u001b[33mWARNING: Ignoring invalid distribution ~umpy (/home/lolowo2/.local/lib/python3.13/site-packages)\u001b[0m\u001b[33m\n",
+ "\u001b[0mProcessing /home/lolowo2/git/PyHealth_full_pipeline\n",
+ " Installing build dependencies ... \u001b[?25ldone\n",
+ "\u001b[?25h Getting requirements to build wheel ... \u001b[?25ldone\n",
+ "\u001b[?25h Preparing metadata (pyproject.toml) ... \u001b[?25ldone\n",
+ "\u001b[?25hBuilding wheels for collected packages: pyhealth\n",
+ " Building wheel for pyhealth (pyproject.toml) ... \u001b[?25ldone\n",
+ "\u001b[?25h Created wheel for pyhealth: filename=pyhealth-2.0.0-py3-none-any.whl size=602972 sha256=b80f6a6f8692914913c8d955aaf3e37bc5b227b1478bd9002a9faf130aa92dac\n",
+ " Stored in directory: /tmp/pip-ephem-wheel-cache-9z0d50r7/wheels/a8/55/b7/a62685e2c4f1fab8fd3203610776bd74fee9d3b37834ede4f0\n",
+ "Successfully built pyhealth\n",
+ "Installing collected packages: pyhealth\n",
+ " Attempting uninstall: pyhealth\n",
+ " Found existing installation: pyhealth 2.0.0\n",
+ " Uninstalling pyhealth-2.0.0:\n",
+ " Successfully uninstalled pyhealth-2.0.0\n",
+ "\u001b[33mWARNING: Ignoring invalid distribution ~orch (/home/lolowo2/.local/lib/python3.13/site-packages)\u001b[0m\u001b[33m\n",
+ "\u001b[0m\u001b[33mWARNING: Ignoring invalid distribution ~umpy (/home/lolowo2/.local/lib/python3.13/site-packages)\u001b[0m\u001b[33m\n",
+ "\u001b[0mSuccessfully installed pyhealth-2.0.0\n",
+ "\u001b[33mWARNING: Ignoring invalid distribution ~orch (/home/lolowo2/.local/lib/python3.13/site-packages)\u001b[0m\u001b[33m\n",
+ "\u001b[0m\u001b[33mWARNING: Ignoring invalid distribution ~umpy (/home/lolowo2/.local/lib/python3.13/site-packages)\u001b[0m\u001b[33m\n",
+ "\u001b[0m\u001b[33mWARNING: Ignoring invalid distribution ~orch (/home/lolowo2/.local/lib/python3.13/site-packages)\u001b[0m\u001b[33m\n",
+ "\u001b[0m\u001b[33mWARNING: Ignoring invalid distribution ~umpy (/home/lolowo2/.local/lib/python3.13/site-packages)\u001b[0m\u001b[33m\n",
+ "\u001b[0m\u001b[33mWARNING: Ignoring invalid distribution ~orch (/home/lolowo2/.local/lib/python3.13/site-packages)\u001b[0m\u001b[33m\n",
+ "\u001b[0m\u001b[33mWARNING: Ignoring invalid distribution ~umpy (/home/lolowo2/.local/lib/python3.13/site-packages)\u001b[0m\u001b[33m\n",
+ "\u001b[0mRequirement already satisfied: ipywidgets in /home/lolowo2/.local/lib/python3.13/site-packages (8.1.8)\n",
+ "Requirement already satisfied: comm>=0.1.3 in /home/lolowo2/.local/lib/python3.13/site-packages (from ipywidgets) (0.2.3)\n",
+ "Requirement already satisfied: ipython>=6.1.0 in /home/lolowo2/.local/lib/python3.13/site-packages (from ipywidgets) (9.10.0)\n",
+ "Requirement already satisfied: traitlets>=4.3.1 in /home/lolowo2/.local/lib/python3.13/site-packages (from ipywidgets) (5.14.3)\n",
+ "Requirement already satisfied: widgetsnbextension~=4.0.14 in /home/lolowo2/.local/lib/python3.13/site-packages (from ipywidgets) (4.0.15)\n",
+ "Requirement already satisfied: jupyterlab_widgets~=3.0.15 in /home/lolowo2/.local/lib/python3.13/site-packages (from ipywidgets) (3.0.16)\n",
+ "Requirement already satisfied: decorator>=4.3.2 in /home/lolowo2/.local/lib/python3.13/site-packages (from ipython>=6.1.0->ipywidgets) (5.2.1)\n",
+ "Requirement already satisfied: ipython-pygments-lexers>=1.0.0 in /home/lolowo2/.local/lib/python3.13/site-packages (from ipython>=6.1.0->ipywidgets) (1.1.1)\n",
+ "Requirement already satisfied: jedi>=0.18.1 in /home/lolowo2/.local/lib/python3.13/site-packages (from ipython>=6.1.0->ipywidgets) (0.19.2)\n",
+ "Requirement already satisfied: matplotlib-inline>=0.1.5 in /home/lolowo2/.local/lib/python3.13/site-packages (from ipython>=6.1.0->ipywidgets) (0.2.1)\n",
+ "Requirement already satisfied: pexpect>4.3 in /home/lolowo2/.local/lib/python3.13/site-packages (from ipython>=6.1.0->ipywidgets) (4.9.0)\n",
+ "Requirement already satisfied: prompt_toolkit<3.1.0,>=3.0.41 in /home/lolowo2/.local/lib/python3.13/site-packages (from ipython>=6.1.0->ipywidgets) (3.0.52)\n",
+ "Requirement already satisfied: pygments>=2.11.0 in /home/lolowo2/.local/lib/python3.13/site-packages (from ipython>=6.1.0->ipywidgets) (2.19.2)\n",
+ "Requirement already satisfied: stack_data>=0.6.0 in /home/lolowo2/.local/lib/python3.13/site-packages (from ipython>=6.1.0->ipywidgets) (0.6.3)\n",
+ "Requirement already satisfied: wcwidth in /home/lolowo2/.local/lib/python3.13/site-packages (from prompt_toolkit<3.1.0,>=3.0.41->ipython>=6.1.0->ipywidgets) (0.6.0)\n",
+ "Requirement already satisfied: parso<0.9.0,>=0.8.4 in /home/lolowo2/.local/lib/python3.13/site-packages (from jedi>=0.18.1->ipython>=6.1.0->ipywidgets) (0.8.6)\n",
+ "Requirement already satisfied: ptyprocess>=0.5 in /home/lolowo2/.local/lib/python3.13/site-packages (from pexpect>4.3->ipython>=6.1.0->ipywidgets) (0.7.0)\n",
+ "Requirement already satisfied: executing>=1.2.0 in /home/lolowo2/.local/lib/python3.13/site-packages (from stack_data>=0.6.0->ipython>=6.1.0->ipywidgets) (2.2.1)\n",
+ "Requirement already satisfied: asttokens>=2.1.0 in /home/lolowo2/.local/lib/python3.13/site-packages (from stack_data>=0.6.0->ipython>=6.1.0->ipywidgets) (3.0.1)\n",
+ "Requirement already satisfied: pure-eval in /home/lolowo2/.local/lib/python3.13/site-packages (from stack_data>=0.6.0->ipython>=6.1.0->ipywidgets) (0.2.3)\n",
+ "\u001b[33mWARNING: Ignoring invalid distribution ~orch (/home/lolowo2/.local/lib/python3.13/site-packages)\u001b[0m\u001b[33m\n",
+ "\u001b[0m\u001b[33mWARNING: Ignoring invalid distribution ~umpy (/home/lolowo2/.local/lib/python3.13/site-packages)\u001b[0m\u001b[33m\n",
+ "\u001b[0m\u001b[33mWARNING: Ignoring invalid distribution ~orch (/home/lolowo2/.local/lib/python3.13/site-packages)\u001b[0m\u001b[33m\n",
+ "\u001b[0m\u001b[33mWARNING: Ignoring invalid distribution ~umpy (/home/lolowo2/.local/lib/python3.13/site-packages)\u001b[0m\u001b[33m\n",
+ "\u001b[0m\u001b[33mWARNING: Ignoring invalid distribution ~orch (/home/lolowo2/.local/lib/python3.13/site-packages)\u001b[0m\u001b[33m\n",
+ "\u001b[0m\u001b[33mWARNING: Ignoring invalid distribution ~umpy (/home/lolowo2/.local/lib/python3.13/site-packages)\u001b[0m\u001b[33m\n",
+ "\u001b[0m\u001b[33mWARNING: Ignoring invalid distribution ~orch (/home/lolowo2/.local/lib/python3.13/site-packages)\u001b[0m\u001b[33m\n",
+ "\u001b[0m\u001b[33mWARNING: Ignoring invalid distribution ~umpy (/home/lolowo2/.local/lib/python3.13/site-packages)\u001b[0m\u001b[33m\n",
+ "\u001b[0m"
+ ]
+ }
+ ],
+ "source": [
+ "!pip install --user --force-reinstall --no-deps /home/lolowo2/git/PyHealth_full_pipeline\n",
+ "!pip install --user ipywidgets"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "No config path provided, using default config\n",
+ "Initializing mimic3 dataset from /home/lolowo2 (dev mode: False)\n",
+ "Using provided cache_dir: /tmp/tmpcxipj_37/4f338cfd-b388-50e8-9d9c-fa4872e51b6c\n",
+ "No cached event dataframe found. Creating: /tmp/tmpcxipj_37/4f338cfd-b388-50e8-9d9c-fa4872e51b6c/global_event_df.parquet\n",
+ "Scanning table: patients from /home/lolowo2/PATIENTS.csv.gz\n",
+ "Scanning table: admissions from /home/lolowo2/ADMISSIONS.csv.gz\n",
+ "Scanning table: icustays from /home/lolowo2/ICUSTAYS.csv.gz\n",
+ "Scanning table: diagnoses_icd from /home/lolowo2/DIAGNOSES_ICD.csv.gz\n",
+ "Joining with table: /home/lolowo2/ADMISSIONS.csv.gz\n",
+ "Scanning table: procedures_icd from /home/lolowo2/PROCEDURES_ICD.csv.gz\n",
+ "Joining with table: /home/lolowo2/ADMISSIONS.csv.gz\n",
+ "Scanning table: prescriptions from /home/lolowo2/PRESCRIPTIONS.csv.gz\n",
+ "Joining with table: /home/lolowo2/ADMISSIONS.csv.gz\n",
+ "Caching event dataframe to /tmp/tmpcxipj_37/4f338cfd-b388-50e8-9d9c-fa4872e51b6c/global_event_df.parquet...\n",
+ "Dataset: mimic3\n",
+ "Dev mode: False\n",
+ "Number of patients: 46520\n",
+ "Number of events: 5214620\n"
+ ]
+ }
+ ],
+ "source": [
+ "import tempfile\n",
+ "\n",
+ "from pyhealth.datasets import MIMIC3Dataset\n",
+ "\n",
+ "base_dataset = MIMIC3Dataset(\n",
+ " root=\"/home/lolowo2\",\n",
+ " tables=[\"DIAGNOSES_ICD\", \"PROCEDURES_ICD\", \"PRESCRIPTIONS\"],\n",
+ " cache_dir=tempfile.TemporaryDirectory().name,\n",
+ " dev=False,\n",
+ ")\n",
+ "\n",
+ "base_dataset.stats()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Step 2: Define the Mortality Prediction Task\n",
+ "\n",
+ "The `MortalityPredictionMIMIC3` task extracts samples from the raw EHR data:\n",
+ "- Extracts diagnosis codes (ICD-9), procedure codes, and drug information from each visit\n",
+ "- Creates binary labels based on in-hospital mortality\n",
+ "- Filters out visits without sufficient clinical codes\n",
+ "\n",
+ "We override the task's `input_schema` to enable `code_mapping` on each sequence feature.\n",
+ "This is the **only difference** from the baseline notebook."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Setting task MortalityPredictionMIMIC3 for mimic3 base dataset...\n",
+ "Task cache paths: task_df=/tmp/tmpcxipj_37/4f338cfd-b388-50e8-9d9c-fa4872e51b6c/tasks/MortalityPredictionMIMIC3_c67969dc-13b3-5ab7-977f-60956867cc5d/task_df.ld, samples=/tmp/tmpcxipj_37/4f338cfd-b388-50e8-9d9c-fa4872e51b6c/tasks/MortalityPredictionMIMIC3_c67969dc-13b3-5ab7-977f-60956867cc5d/samples_cdbbc602-34e2-5a41-8643-4c76b08829f6.ld\n",
+ "Applying task transformations on data with 1 workers...\n",
+ "Detected Jupyter notebook environment, setting num_workers to 1\n",
+ "Single worker mode, processing sequentially\n",
+ "Worker 0 started processing 46520 patients. (Polars threads: 16)\n"
+ ]
+ },
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ " 0%| | 0/46520 [00:00, ?it/s]"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Rank 0 inferred the following `['bytes']` data format.\n"
+ ]
+ },
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "100%|██████████| 46520/46520 [01:03<00:00, 734.65it/s]"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Worker 0 finished processing patients.\n",
+ "Fitting processors on the dataset...\n"
+ ]
+ },
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Label mortality vocab: {0: 0, 1: 1}\n",
+ "Processing samples and saving to /tmp/tmpcxipj_37/4f338cfd-b388-50e8-9d9c-fa4872e51b6c/tasks/MortalityPredictionMIMIC3_c67969dc-13b3-5ab7-977f-60956867cc5d/samples_cdbbc602-34e2-5a41-8643-4c76b08829f6.ld...\n",
+ "Applying processors on data with 1 workers...\n",
+ "Detected Jupyter notebook environment, setting num_workers to 1\n",
+ "Single worker mode, processing sequentially\n",
+ "Worker 0 started processing 9583 samples. (0 to 9583)\n"
+ ]
+ },
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ " 0%| | 0/9583 [00:00, ?it/s]"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Rank 0 inferred the following `['str', 'str', 'no_header_tensor:18', 'no_header_tensor:18', 'no_header_tensor:18', 'no_header_tensor:1']` data format.\n"
+ ]
+ },
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "100%|██████████| 9583/9583 [00:02<00:00, 3953.17it/s]"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Worker 0 finished processing samples.\n"
+ ]
+ },
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Cached processed samples to /tmp/tmpcxipj_37/4f338cfd-b388-50e8-9d9c-fa4872e51b6c/tasks/MortalityPredictionMIMIC3_c67969dc-13b3-5ab7-977f-60956867cc5d/samples_cdbbc602-34e2-5a41-8643-4c76b08829f6.ld\n",
+ "Generated 9583 samples\n",
+ "\n",
+ "Input schema: {'conditions': ('sequence', {'code_mapping': ('ICD9CM', 'CCSCM')}), 'procedures': ('sequence', {'code_mapping': ('ICD9PROC', 'CCSPROC')}), 'drugs': ('sequence', {'code_mapping': ('NDC', 'ATC')})}\n",
+ "Output schema: {'mortality': 'binary'}\n"
+ ]
+ }
+ ],
+ "source": [
+ "from pyhealth.tasks import MortalityPredictionMIMIC3\n",
+ "\n",
+ "task = MortalityPredictionMIMIC3()\n",
+ "\n",
+ "# Enable code_mapping to collapse granular codes into grouped vocabularies\n",
+ "task.input_schema = {\n",
+ " \"conditions\": (\"sequence\", {\"code_mapping\": (\"ICD9CM\", \"CCSCM\")}),\n",
+ " \"procedures\": (\"sequence\", {\"code_mapping\": (\"ICD9PROC\", \"CCSPROC\")}),\n",
+ " \"drugs\": (\"sequence\", {\"code_mapping\": (\"NDC\", \"ATC\")}),\n",
+ "}\n",
+ "\n",
+ "samples = base_dataset.set_task(task)\n",
+ "\n",
+ "print(f\"Generated {len(samples)} samples\")\n",
+ "print(f\"\\nInput schema: {samples.input_schema}\")\n",
+ "print(f\"Output schema: {samples.output_schema}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Step 3: Dataset Statistics\n",
+ "\n",
+ "Each sample represents one hospital visit with:\n",
+ "- **conditions**: List of mapped CCS diagnosis categories (collapsed from ICD-9)\n",
+ "- **procedures**: List of mapped CCS procedure categories (collapsed from ICD-9 PROC)\n",
+ "- **drugs**: List of drug codes (NDC → ATC mapping attempted, falls back to raw if no match)\n",
+ "- **mortality**: Binary label (0 = survived, 1 = deceased)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Sample structure:\n",
+ "{'hadm_id': '164713', 'patient_id': '10004', 'conditions': tensor([ 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]), 'procedures': tensor([2, 3, 4, 5, 6, 7, 8]), 'drugs': tensor([ 2, 3, 4, 5, 6, 7, 8, 9, 10, 10, 11, 11, 11, 12, 12, 13, 14, 11,\n",
+ " 15, 11, 16, 17, 17, 11, 18, 2, 3, 19, 20, 21, 22, 11, 20, 21, 22, 20,\n",
+ " 21, 22, 11, 12, 11, 23, 24, 25, 26, 27, 28, 11, 29, 30, 31, 32, 14, 24,\n",
+ " 25, 33, 26, 34, 34, 34, 35, 29, 30, 32, 36, 37, 38, 39, 38, 39, 34, 38,\n",
+ " 39, 40, 41, 9, 14, 14, 7, 5, 6, 9, 9, 8, 9, 9, 42, 14, 14, 5,\n",
+ " 6, 11, 2, 3, 17, 17, 9, 9, 28, 28, 27, 43, 44, 13, 45, 46, 11, 9,\n",
+ " 4, 28, 3, 23, 24, 25, 26, 24, 25, 33, 26, 24, 25, 33, 26, 24, 25, 26,\n",
+ " 47, 46, 19, 14, 46, 48, 49, 14, 46, 50, 51, 48, 5, 6, 14, 49, 52, 49,\n",
+ " 49, 7, 9, 53, 52, 49, 49, 14, 54, 11, 50, 51, 14, 55, 50, 51, 12, 55,\n",
+ " 56, 38, 39, 50, 51, 55, 11, 16, 12, 50, 51, 11, 57, 46, 14, 7, 50, 51,\n",
+ " 58, 47, 50, 51, 50, 51, 12, 50, 51, 12, 59, 11, 60, 61, 62, 55, 50, 51,\n",
+ " 57, 9, 19, 59, 9, 9, 12, 50, 51, 50, 51, 12, 50, 51, 50, 51, 50, 51,\n",
+ " 12, 50, 51, 12, 50, 51, 9, 9, 9, 24, 25, 33, 26, 44, 43, 63, 4, 34,\n",
+ " 34, 9, 60, 61, 12, 50, 51, 62, 57, 11, 58, 55, 50, 51, 24, 25, 26, 23,\n",
+ " 5, 6, 46, 14, 49, 48, 64, 47, 59, 12, 64, 45, 65, 11, 50, 51, 57, 12,\n",
+ " 50, 51, 34, 34]), 'mortality': tensor([0.])}\n",
+ "\n",
+ "==================================================\n",
+ "Processor Vocabulary Sizes:\n",
+ "==================================================\n",
+ "conditions: 268 codes (including , )\n",
+ "procedures: 204 codes (including , )\n",
+ "drugs: 1295 codes (including , )\n",
+ "\n",
+ "Total samples: 9583\n",
+ "Mortality rate: 12.05%\n",
+ "Positive samples: 1155\n",
+ "Negative samples: 8428\n"
+ ]
+ }
+ ],
+ "source": [
+ "print(\"Sample structure:\")\n",
+ "print(samples[0])\n",
+ "\n",
+ "print(\"\\n\" + \"=\" * 50)\n",
+ "print(\"Processor Vocabulary Sizes:\")\n",
+ "print(\"=\" * 50)\n",
+ "for key, proc in samples.input_processors.items():\n",
+ " if hasattr(proc, 'code_vocab'):\n",
+ " print(f\"{key}: {len(proc.code_vocab)} codes (including