Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# Changelog

All notable changes to PyHealth are documented here. Versions follow the
`major.minor.patch` scheme; see the
[releases page](https://github.com/sunlabuiuc/PyHealth/releases) for the
corresponding tags and wheels.

## 2.0.2 — 2026-09-02

The first release since 2.0.1 (2026-03-30). It adds four datasets, a synthetic-EHR
generation and evaluation stack, several new models and interpretability methods, and
a large batch of correctness fixes across models, tasks, metrics, and calibration.
No public API was removed or renamed — 2.0.1 code continues to work unchanged.

### New datasets

- **MEDS** — `MEDSDataset` plus a typed Parquet scan path on `BaseDataset`. `.parquet`
/ `.pq` files, globs, and directories now route through a typed `_scan_parquet`
scanner, with a datetime fast path that skips the string round-trip. Includes the
`in_hospital_mortality_meds` task. ([#1179](https://github.com/sunlabuiuc/PyHealth/pull/1179))
- **FHIR** — a full FHIR pipeline under `pyhealth/datasets/fhir/`, including a
MIMIC-IV-on-FHIR dataset. ([#1155](https://github.com/sunlabuiuc/PyHealth/pull/1155))
- **EEGBCI** — dataset, helper functions, and tasks. ([#1177](https://github.com/sunlabuiuc/PyHealth/pull/1177))
- **PhysioNet De-Identification** — dataset, NER task (`deid_ner`), and the
`TransformerDeID` model. ([#981](https://github.com/sunlabuiuc/PyHealth/pull/981))

### New models

- **MedFuse** — multi-modal fusion of EHR time series with chest X-rays. ([#1003](https://github.com/sunlabuiuc/PyHealth/pull/1003))
- **Synthetic EHR generators** — `pyhealth/models/generators/`: HALO, MedGAN, CorGAN,
PromptEHR, and a GPT-2 generator, with the `generate_ehr` task. ([#1148](https://github.com/sunlabuiuc/PyHealth/pull/1148))
- **CaliForest** — calibrated random forest; requires an explicit `fit` before
inference. ([#999](https://github.com/sunlabuiuc/PyHealth/pull/999))
- **GRASP** — migrated from the 1.x API to 2.0, with `static_key` support for
demographic features. ([#905](https://github.com/sunlabuiuc/PyHealth/pull/905))

### New tasks and metrics

- **`DrugRecommendationOMOP`** — a class-based OMOP drug recommendation task. ([#1203](https://github.com/sunlabuiuc/PyHealth/pull/1203))
- **Generative evaluation metrics** — `pyhealth/metrics/generative/` scores synthetic
EHR data on privacy (NNAAR, membership inference, discriminator privacy), utility,
and statistical fidelity. ([#1148](https://github.com/sunlabuiuc/PyHealth/pull/1148))
- **Attention rollout** — interpretability method of Abnar & Zuidema (2020). ([#1158](https://github.com/sunlabuiuc/PyHealth/pull/1158))
- **Conformal prediction** — real Adaptive Prediction Sets (Romano, Sesia & Candès
2020) in the new `pyhealth/calib/predictionset/scores.py`, with a dynamic
`score_type` on conformal methods ([#1189](https://github.com/sunlabuiuc/PyHealth/pull/1189)),
plus additional conformal methods and example scripts ([#942](https://github.com/sunlabuiuc/PyHealth/pull/942)).

### Restored from 1.x

- **`code_mapping`** — `SequenceProcessor` accepts an optional `code_mapping` that
collapses granular codes into grouped vocabularies (ICD9CM→CCSCM, ICD9PROC→CCSPROC,
NDC→ATC) before building the embedding table, and `BaseTask.__init__` accepts it
directly so schemas no longer have to be patched by hand. Closes the functional gap
left by the 1.x→2.0 rewrite. ([#905](https://github.com/sunlabuiuc/PyHealth/pull/905), ref [#535](https://github.com/sunlabuiuc/PyHealth/issues/535))

### Fixes

**Data leakage and label correctness**

- StageNet MIMIC-IV mortality/LOS tasks leaked post-outcome information: diagnosis and
procedure codes are timestamped at `dischtime`, so for the admission being predicted
they are only known at or after the outcome, and labs were pulled through
discharge/death. Those codes are now excluded for that admission and its labs capped
to the first 48 hours. ([#1205](https://github.com/sunlabuiuc/PyHealth/pull/1205))
- `drug_recommendation_omop_fn` never excluded the current visit's own drugs from
`drugs_all`, making the last history entry identical to the prediction target. ([#1203](https://github.com/sunlabuiuc/PyHealth/pull/1203))
- Drug tasks extracted `event.drug` (drug *names*, e.g. "Aspirin"), which produce zero
matches in the NDC→ATC CrossMap; they now extract `event.ndc`. ([#905](https://github.com/sunlabuiuc/PyHealth/pull/905))
- Drug recommendation NDC/ATC3 code handling and padding behaviour. ([#1138](https://github.com/sunlabuiuc/PyHealth/pull/1138))

**Models**

- `CNN` crashed on 1-D tensor and multi-hot inputs: `forward` hardcoded a 3-D
expectation for `spatial_dim=1`, but `MultiHotProcessor` and 1-D `TensorProcessor`
inputs embed to `[batch, embedding_dim]` with no sequence axis. Now treated as a
length-1 sequence. ([#1208](https://github.com/sunlabuiuc/PyHealth/pull/1208))
- `TCN` crashed on tuple-schema features by passing raw kwargs (including
`StageNetProcessor`'s `(time, value)` tuples) to the embedding model; it now unwraps
the `value` tensor first, like its sibling sequence models. ([#1212](https://github.com/sunlabuiuc/PyHealth/pull/1212))
- `BIOT` hardcoded `nn.Embedding(n_channels, 256)` for channel tokens, crashing for
any `emb_size != 256`. ([#1213](https://github.com/sunlabuiuc/PyHealth/pull/1213))
- `MoleRec`'s no-SMILES fallback predictor was created lazily inside `forward`, so an
optimizer built from `model.parameters()` beforehand never saw its parameters and it
never trained. It is now created in `__init__`. ([#1214](https://github.com/sunlabuiuc/PyHealth/pull/1214))
- `SdohClassifier` was an `nn.Module` decorated with `@dataclass`, whose generated
`__init__` never called `nn.Module.__init__`, leaving the module without
`_parameters`/`_modules` and unusable in torch. ([#1209](https://github.com/sunlabuiuc/PyHealth/pull/1209))
- `SinusoidalTimeEmbedding` divided frequency indices by `half - 1`, so `dim=2` gave
0/0 and every embedding was NaN. Clamped to at least 1. ([#1216](https://github.com/sunlabuiuc/PyHealth/pull/1216))
- Sparsemax in `AdaCare`. ([#1139](https://github.com/sunlabuiuc/PyHealth/pull/1139))
- `RNNLayer` and `ConCare` crashed on zero-length sequences and on `batch_size=1`;
`GRASP` collapsed its hidden state at `batch_size=1` and raised when
`cluster_num > batch_size`. ([#905](https://github.com/sunlabuiuc/PyHealth/pull/905))
- MedLink: `collate_fn` built output keys from only the first sample in a batch, so a
batch mixing samples with and without a mined hard negative (`s_n`) either raised
`KeyError` or silently produced a misaligned list; keys are now unioned across the
batch. ([#1222](https://github.com/sunlabuiuc/PyHealth/pull/1222))
- MedLink BM25 hard-negative mining did not preserve all positives. ([#1195](https://github.com/sunlabuiuc/PyHealth/pull/1195))

**Datasets and tasks**

- Patient merging crashed on tables with null `patient_id`. ([#1193](https://github.com/sunlabuiuc/PyHealth/pull/1193))
- `SampleDataset` subset mappings were wrong. ([#1211](https://github.com/sunlabuiuc/PyHealth/pull/1211))
- `PatientLinkageMIMIC3Task`'s `input_schema` named `"integer"`/`"string"`/
`"datetime"` processors, none of which are registered, so `set_task()` failed
immediately with `ValueError: Unknown processor`. ([#1204](https://github.com/sunlabuiuc/PyHealth/pull/1204))

**Metrics, calibration, and interpretability**

- `ece_confidence_binary` indexed `prob[:, 0]`/`label[:, 0]`, requiring 2-D arrays,
but its only caller passes 1-D positive-class probabilities and 1-D 0/1 labels — so
`ECE` and `ECE_adapt` always raised `IndexError` on binary tasks. ([#1215](https://github.com/sunlabuiuc/PyHealth/pull/1215))
- `disparate_impact` and `statistical_parity_difference` returned `nan` for empty
subgroups instead of raising: the rate was a numpy 0/0, and since `nan == 0` is
always `False` the existing zero guard never caught it (and only ever checked the
unprotected group). ([#1199](https://github.com/sunlabuiuc/PyHealth/pull/1199))
- `fairness_metrics_fn` was commented out of `pyhealth.metrics`; re-enabled and added
to `__all__`. ([#1200](https://github.com/sunlabuiuc/PyHealth/pull/1200))
- Removal-based interpretability metrics aliased `original_class_probs` to `y_probs`
and negated negative-class entries in place, flipping the sign on every iteration of
the percentage loop — a sample's score depended on where its percentage sat in the
list. ([#1196](https://github.com/sunlabuiuc/PyHealth/pull/1196))
- Interpretability `target_class_idx` handling, argument naming, and sample-class
filtering. ([#926](https://github.com/sunlabuiuc/PyHealth/pull/926))
- SCRIB: the overall-risk loss squared the chance-ambiguity term, contradicting Eq. 2
and Algorithm 2 of the paper and the already-correct class-specific loss in the same
file; fixed in both the Python and Cython paths, along with a `fill_max` inference
gap. ([#1190](https://github.com/sunlabuiuc/PyHealth/pull/1190))
- Covariate-shift conformal prediction fixes. ([#1180](https://github.com/sunlabuiuc/PyHealth/pull/1180))

**Examples and docs**

- Removed the deprecated `code_mapping`, `dev`, and `refresh_cache` arguments from
`README.rst`, example scripts, and leaderboard utilities — the 2.0
`MIMIC3Dataset`/`MIMIC4Dataset` no longer accept them. ([#935](https://github.com/sunlabuiuc/PyHealth/pull/935), fixes [#535](https://github.com/sunlabuiuc/PyHealth/issues/535))
- Fixed a `SyntaxError` in `examples/benchmark_perf/loc/minimal_los.py`, a missing
`__main__` guard that hung `readmission_mimic3_fairness.py` under multiprocessing,
and a stale `Transformer(...)` call. ([#1200](https://github.com/sunlabuiuc/PyHealth/pull/1200))
- Reinitialized the documentation tutorials lost in the UIUC purge and re-linked the
Colab notebooks. ([#1143](https://github.com/sunlabuiuc/PyHealth/pull/1143), [#1146](https://github.com/sunlabuiuc/PyHealth/pull/1146))
- Added missing paper citations throughout the codebase. ([#1181](https://github.com/sunlabuiuc/PyHealth/pull/1181))

### Infrastructure

- CI gate enforcing the PR contribution rules for changes under `pyhealth/`
(`tools/check_pr_rules.py`). ([#1176](https://github.com/sunlabuiuc/PyHealth/pull/1176))
- Unit tests for `RNN` and `MultimodalRNN`. ([#936](https://github.com/sunlabuiuc/PyHealth/pull/936))
- Fixed a pixi warning and the version format for the build backend. ([#917](https://github.com/sunlabuiuc/PyHealth/pull/917))
- `tools/bump_version.py` now keeps `pyhealth.__version__` in sync with
`pyproject.toml`, rewrites only the `[project]` version line, and no longer hangs
when bumping from a non-pre-release version.

**Full Changelog**: https://github.com/sunlabuiuc/PyHealth/compare/v2.0.1...v2.0.2
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ for docstrings.
Any pull request that modifies a file under `pyhealth/` must also:

- Update at least one file under `docs/` and one file under `examples/`.
Release bumps are exempt: a file whose only change is the `__version__`
assignment does not count as a source change.
- Keep newly added/modified lines free of [ruff](https://docs.astral.sh/ruff/)
lint violations (`ruff check`, 88-char line length). Pre-existing lint
issues elsewhere in a touched file are not blocked.
Expand Down
29 changes: 28 additions & 1 deletion docs/log.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,33 @@
Development logs
======================
We track the new development here:
We track the new development here. Starting with 2.0.2, per-release changes are
recorded by version in
`CHANGELOG.md <https://github.com/sunlabuiuc/PyHealth/blob/master/CHANGELOG.md>`_;
the dated entries below cover earlier development.

Releases
--------

**2.0.2** (Sep 02, 2026) --
`full changelog <https://github.com/sunlabuiuc/PyHealth/blob/master/CHANGELOG.md#202--2026-09-02>`_

.. code-block:: rst

1. new datasets: MEDS (#1179), FHIR (#1155), EEGBCI (#1177), PhysioNet
De-Identification (#981).
2. new models: MedFuse (#1003), synthetic-EHR generators HALO/MedGAN/CorGAN/
PromptEHR/GPT-2 (#1148), CaliForest (#999), GRASP on the 2.0 API (#905).
3. new tasks and metrics: DrugRecommendationOMOP (#1203), generative
evaluation metrics (#1148), attention rollout interpretability (#1158),
real Adaptive Prediction Sets for conformal prediction (#1189).
4. restored code_mapping from 1.x on SequenceProcessor and BaseTask (#905).
5. data leakage fixes that change benchmark numbers: StageNet MIMIC-IV
mortality/LOS (#1205) and drug_recommendation_omop_fn (#1203).
6. correctness fixes across CNN, TCN, BIOT, MoleRec, SDOH, AdaCare, GRASP,
MedLink, ECE, fairness metrics, SCRIB, and interpretability metrics.

Development history
-------------------

**Dec 29, 2023**

Expand Down
2 changes: 1 addition & 1 deletion pyhealth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from pathlib import Path
import sys

__version__ = "2.0.0"
__version__ = "2.0.2"

# package-level cache path
BASE_CACHE_PATH = os.path.join(str(Path.home()), ".cache/pyhealth/")
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
[project]
name = "pyhealth"
# must be kept in sync with git tags; is not updated by any automation tools
version = "2.0.1"
version = "2.0.2"
authors = [
{name = "John Wu", email = "johnwu3@illinois.edu"},
{name = "Chaoqi Yang"},
Expand Down
44 changes: 44 additions & 0 deletions tools/bump_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@

ROOT = os.path.dirname(os.path.dirname(__file__))
PYPROJECT = os.path.join(ROOT, "pyproject.toml")
INIT_PY = os.path.join(ROOT, "pyhealth", "__init__.py")


VERSION_RE = re.compile(
Expand All @@ -60,14 +61,39 @@ def extract_version(text: str) -> str:


def replace_version(text: str, new_version: str) -> str:
# count=1: only the [project] version (the first ``version = "..."`` line in
# the file) is the package version. Later sections carry unrelated pins --
# e.g. [tool.pixi.package.build.backend] version -- which must not be
# rewritten.
return re.sub(
r"^(version\s*=\s*\")[^\"]+(\")",
rf"\g<1>{new_version}\2",
text,
count=1,
flags=re.M,
)


def sync_dunder_version(new_version: str) -> None:
"""Rewrite ``__version__`` in pyhealth/__init__.py to match pyproject.toml."""
with open(INIT_PY, "r", encoding="utf-8") as f:
text = f.read()
new_text, n = re.subn(
r"^(__version__\s*=\s*\")[^\"]+(\")",
rf"\g<1>{new_version}\2",
text,
flags=re.M,
)
if n == 0:
print(
f"Warning: __version__ not found in {INIT_PY}; not synced.",
file=sys.stderr,
)
return
with open(INIT_PY, "w", encoding="utf-8") as f:
f.write(new_text)


def parse_version(v: str):
m = VERSION_RE.match(v)
if not m:
Expand Down Expand Up @@ -182,6 +208,16 @@ def _find_minimum_available_version(cur_version: str, existing: set[str]) -> str
"""
major, minor, patch, pre_l, pre_n = parse_version(cur_version)

# Normal (non-pre-release) versions have no pre-release number to search
# over; walk the patch number instead, otherwise every candidate would
# render identically to cur_version and the search below never terminates.
if pre_l is None:
while True:
candidate = fmt_version(major, minor, patch, None, None, force_patch=True)
if not _version_exists_on_pypi(candidate, existing):
return candidate
patch += 1

# Find the highest version on PyPI with same major.minor[.patch] and type
max_pre_n = -1
for pypi_ver in existing:
Expand Down Expand Up @@ -238,6 +274,12 @@ def _find_next_available_version(
Next available version string
"""
major, minor, patch, pre_l, pre_n = parse_version(cur_version)
# Converting a normal release into a pre-release: start the search at a0,
# matching bump_alpha_minor/bump_alpha_major.
if pre_l is None:
pre_l = "a"
if pre_n is None:
pre_n = 0

# Find the highest matching version on PyPI
max_pre_n = -1
Expand Down Expand Up @@ -382,11 +424,13 @@ def main():

if args.dry_run:
print(f"Would bump version: {cur} -> {new}")
print(f"Would sync __version__ in {INIT_PY} to {new}")
return 0

new_text = replace_version(text, new)
with open(PYPROJECT, "w", encoding="utf-8") as f:
f.write(new_text)
sync_dunder_version(new)
print(f"Bumped version: {cur} -> {new}")
return 0

Expand Down
37 changes: 33 additions & 4 deletions tools/check_pr_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
Rules enforced whenever a PR touches pyhealth/**/*.py:

1. Docs/examples: the PR must also modify at least one file under
docs/** and one file under examples/**.
docs/** and one file under examples/**. A file whose only change is
the __version__ assignment (i.e. a release bump) does not count as a
source change and does not trigger this rule.
2. Lint: lines added or modified in touched pyhealth/**/*.py files must
be free of ruff violations. Pre-existing violations elsewhere in a
touched file are not flagged.
Expand All @@ -19,6 +21,7 @@
import argparse
import ast
import json
import re
import subprocess
import sys
from pathlib import Path
Expand Down Expand Up @@ -53,8 +56,34 @@ def added_lines(base, head, path):
return lines


def check_docs_examples(files):
if not any(f.startswith("pyhealth/") and f.endswith(".py") for f in files):
VERSION_LINE = re.compile(r"^[+-]__version__\s*=")


def is_version_only_change(base, head, path):
"""True if the only edit to `path` is the ``__version__`` assignment.

A release commit bumps ``pyhealth/__init__.py`` and nothing else under
``pyhealth/``. That is neither new code nor new behaviour, so it should not
trigger the docs/examples requirement.
"""
out = sh("git", "diff", "--unified=0", f"{base}..{head}", "--", path)
changed = [
line
for line in out.splitlines()
if line[:1] in "+-" and not line.startswith(("+++", "---"))
]
return bool(changed) and all(VERSION_LINE.match(line) for line in changed)


def check_docs_examples(files, base, head):
sources = [
f
for f in files
if f.startswith("pyhealth/")
and f.endswith(".py")
and not is_version_only_change(base, head, f)
]
if not sources:
return []
problems = []
if not any(f.startswith("docs/") for f in files):
Expand Down Expand Up @@ -136,7 +165,7 @@ def main():

files = changed_files(args.base, args.head)
problems = (
check_docs_examples(files)
check_docs_examples(files, args.base, args.head)
+ check_lint(files, args.base, args.head)
+ check_docstring_examples(files, args.base, args.head)
)
Expand Down
Loading