Skip to content

Add minimal base abstraction for pyhealth.benchmarks - #1329

Open
fbonc wants to merge 3 commits into
sunlabuiuc:masterfrom
fbonc:benchmarks
Open

fbonc wants to merge 3 commits into
sunlabuiuc:masterfrom
fbonc:benchmarks

Conversation

@fbonc

@fbonc fbonc commented Oct 26, 2026

Copy link
Copy Markdown
Contributor

This PR adds the base abstraction for a pyhealth.benchmarks package: Benchmark, Evaluation, and Result. Concrete benchmarks (starting with CPBench) will subclass it.

It is deliberately minimal. It contains only what is needed to define, validate, and execute a batch of evaluations. Anything consumed only by the job runner, result storage, or leaderboard is left out until those components exist. The "Deferred" section below lists each omission and what would trigger adding it. Every item there can be added later without breaking existing subclasses.

Design decisions

  • The base class is agnostic to PyHealth internals. It knows how to identify, validate, and execute evaluations. It knows nothing about datasets, models, training, conformal methods, or metrics. Execution logic, caching, and optimizations all belong to individual benchmarks.
  • A benchmark is not a runner or a leaderboard. It defines evaluations and executes them. Deciding which evaluations to run, when to run them, and how to publish results is left to external infrastructure.
  • Evaluation identity is defined by its params. Evaluation.id is a SHA-256 of the canonical JSON of params (sorted keys). Evaluations are compared and hashed by id, so they work as dict keys and storage keys. An id is unique within one benchmark, so storage should key on (benchmark.name, evaluation.id).
  • No registries. Classes and functions appear in params as Python objects and are serialized by import path, e.g. {"__type__": "pyhealth.models.rnn.RNN"}. This follows the import-path approach already used by pyhealth.autoimprove.ModelSpec. Evaluation.from_dict resolves these paths back to objects, so evaluations round-trip through JSON and YAML. Public re-export paths are accepted, and the id always uses the defining module, so pyhealth.models.RNN and pyhealth.models.rnn.RNN give the same id. "__type__" is a reserved key in params.
  • Params are validated at construction. Computing the id serializes the params, so non-JSON values, NaN, lambdas, locally defined classes, and the reserved "__type__" key fail immediately.
  • Partial failure is supported. Result.status is "success" or "failed". _evaluate is expected to catch per-evaluation failures and return failed Results, so one crash doesn't discard a whole batch. An exception that escapes _evaluate means the whole batch failed.
  • Validation is guaranteed. evaluate() is final, and it runs validate_evaluations → _evaluate → validate_results. Subclasses implement _evaluate and extend the validators via super(). The base validators reject duplicate evaluations and require exactly one result per evaluation. Validation reports every problem at once.
  • Error text is private. Exception messages and tracebacks can quote patient data, so metadata["error"] must never be published. metadata["error_type"] (the exception class name) is the display-safe field.
  • Cache contract. cache_dir may be shared by concurrent jobs, so benchmarks must write atomically (temporary file, then os.replace). Benchmarks own their cache keys and reuse decisions.
  • The interface is kept separate from the serialization machinery. base.py contains the public interface and its contracts. _serialization.py holds the private helpers for canonical JSON, encoding import paths, and resolving them. All helpers are _-prefixed and tested only through the public API, so they can be refactored freely.

Known trade-offs

  • params must not be mutated after construction, because the id is cached and is not deep-frozen.
  • Moving a referenced class to another module changes the id, even when its public import path is unchanged.
  • No numeric normalization: 1 and 1.0 give different ids.

Deferred (add when needed)

Not included Add when
version property (non-abstract, with a default) The runner reuses stored results and needs to detect stale ones
group_evaluations() scheduling hint The runner splits batches across cluster jobs, and evaluations sharing a checkpoint should run together
Metric metadata (direction, coverage targets) The leaderboard needs to sort and highlight metrics. Coverage targets depend on α, so they likely belong per evaluation, not per benchmark
Custom exception types A caller needs to tell validation failures apart from other ValueErrors
Deep-freezing params Mutation bugs actually show up
Result helper constructors and validation Benchmarks drift into inconsistent failure shapes

Files to review

  • pyhealth/benchmarks/base.py: the public interface (Benchmark, Evaluation, Result) and their contracts
  • pyhealth/benchmarks/_serialization.py: private helpers for canonical JSON, encoding import paths, and resolving them
  • pyhealth/benchmarks/__init__.py: exports
  • tests/core/test_benchmark_base.py: tests (unittest, so they run under both pytest and pixi run test), including the from_dict round trip and a golden-id test that guards the canonical serialization
  • docs/api/benchmarks.rst and docs/index.rst: API docs page and toctree entry

@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Test Coverage

Total: 73.38% (19729 / 26885 lines)

Modified file Coverage Lines
pyhealth/benchmarks/__init__.py 100.00% 2 / 2
pyhealth/benchmarks/_serialization.py 95.95% 71 / 74
pyhealth/benchmarks/base.py 98.33% 59 / 60
pyhealth/calib/calibration/__init__.py 100.00% 6 / 6
pyhealth/calib/calibration/hb.py 75.00% 84 / 112
pyhealth/calib/calibration/logistic_recalibration.py 97.47% 77 / 79
pyhealth/calib/predictionset/__init__.py 100.00% 9 / 9
pyhealth/calib/predictionset/base_conformal/__init__.py 62.89% 61 / 97
pyhealth/calib/predictionset/favmac/core.py 18.58% 21 / 113
pyhealth/calib/predictionset/label_shift/__init__.py 100.00% 4 / 4
pyhealth/calib/predictionset/label_shift/black_box_shift.py 87.69% 57 / 65
pyhealth/calib/predictionset/label_shift/label_shift.py 96.39% 80 / 83
pyhealth/calib/predictionset/label_shift/mlls.py 95.12% 78 / 82
pyhealth/calib/predictionset/rc3p.py 96.06% 122 / 127
pyhealth/calib/predictionset/scrib/quicksearch.py 79.10% 193 / 244
pyhealth/datasets/__init__.py 83.33% 40 / 48
pyhealth/datasets/base_dataset.py 90.84% 476 / 524
pyhealth/datasets/ptbxl.py 83.51% 157 / 188
pyhealth/datasets/sample_dataset.py 94.41% 321 / 340
pyhealth/datasets/splitter.py 96.62% 343 / 355
pyhealth/metrics/__init__.py 100.00% 11 / 11
pyhealth/metrics/binary.py 75.00% 63 / 84
pyhealth/metrics/multiclass.py 100.00% 116 / 116
pyhealth/metrics/multilabel.py 96.52% 111 / 115
pyhealth/metrics/regression.py 55.56% 20 / 36
pyhealth/models/base_model.py 93.01% 133 / 143
pyhealth/models/rnn.py 89.18% 173 / 194
pyhealth/models/transformers_model.py 90.00% 81 / 90
pyhealth/processors/__init__.py 94.12% 32 / 34
pyhealth/processors/temporal_timeseries_processor.py 78.57% 44 / 56
pyhealth/processors/tensor_processor.py 92.50% 74 / 80
pyhealth/processors/timeseries_processor.py 84.48% 98 / 116
pyhealth/processors/utils.py 96.55% 56 / 58
pyhealth/tasks/__init__.py 89.19% 33 / 37
pyhealth/tasks/ptbxl.py 93.42% 71 / 76
pyhealth/tasks/sleep_staging.py 93.33% 84 / 90
pyhealth/trainer.py 72.69% 197 / 271

Workflow run · adceb6e

@Logiquo Logiquo added the status: manual Pending maintainer's review label Oct 29, 2026
@Logiquo

Logiquo commented Oct 29, 2026

Copy link
Copy Markdown
Collaborator

/review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reviewed commit 52eb8e7

The key is reserved for class and function references, so allowing it in
user data would make ``to_dict`` output ambiguous.
"""
if isinstance(obj, Mapping):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[medium] Non-str mapping keys collide with string keys in the id

json.dumps silently converts int, float, bool and None keys to strings, so Evaluation({"x": {1: "a"}}) and Evaluation({"x": {"1": "a"}}) get the same id, compare equal, and are reported as duplicates by validate_evaluations. Evaluation({"x": {True: 1}}) collides with {"true": 1} in the same way. The Evaluation docstring says params are str-keyed mappings, but nothing enforces that. Fix: in the Mapping branch, raise TypeError for any key where not isinstance(key, str), and add a test that {1: ...} is rejected.

Comment thread pyhealth/benchmarks/_serialization.py Outdated
f"{obj!r} is not a module-level class or function and cannot "
"be serialized by import path."
)
return {"__type__": f"{module}.{qualname}"}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[medium] Classes that do not round-trip by import path are accepted

_encode serializes any class or function as module.qualname without checking that the path resolves back to the same object. A class defined in main resolves only in the process that defined it, and a module-level alias made with type() has a qualname that no module attribute matches. In both cases construction succeeds and the id is computed, but Evaluation.from_dict on the stored JSON raises ValueError in a fresh process, which contradicts the design's round-trip guarantee and its claim that bad params fail at construction. Fix: reject module == "main", and check that _resolve(path) is obj, raising TypeError otherwise. Also add tests for both cases.

for i in range(len(parts), 0, -1):
prefix = ".".join(parts[:i])
try:
obj = importlib.import_module(prefix)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[low] Leading-dot path escapes as TypeError, not ValueError

For a path such as ".json", importlib.import_module(".json") raises TypeError (the package argument is required for relative imports). That is not caught by the except clauses in _resolve, so from_dict raises TypeError although its docstring promises ValueError. Fix: reject paths that start with "." or contain empty parts before the loop. Also, from_dict imports any installed module named in its input, and that import can run side effects. The docstring should say that from_dict must only receive trusted data.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

AI Review

suggestion: need change
decision: human review

Thank you for this PR. It adds a minimal pyhealth.benchmarks base layer (Benchmark, Evaluation, Result), a private canonical-serialization module, API docs and tests. The design is clear and well documented, and the validation flow is sensible. Coverage is above the bar: base.py is 98.31%, _serialization.py is 95.16%, __init__.py is 100%, and the whole repository is 70.28% against the 60% bar.

To get this merged:

  1. Non-string mapping keys are not rejected. _check_no_type_key doesn't check key types, and json.dumps coerces keys. So Evaluation({"p": {1: "a"}}) and Evaluation({"p": {"1": "a"}}) get the same id and compare equal. That contradicts the documented "str-keyed mappings" contract and could make two different evaluations collide in storage. Please raise TypeError for non-str keys anywhere in params, and add a test.
  2. Some classes get an id but can't be rebuilt. _encode accepts classes and functions whose module.qualname doesn't resolve back to the same object. Examples are classes defined in __main__ and module-level aliases created with type(). Their ids compute, but from_dict can't rebuild them in another process. That breaks the stated round-trip guarantee. Please either reject such objects at construction, for example by checking that _resolve(path) is obj and excluding __main__, or document the limitation clearly. Please add a test either way.

Optional suggestions:

  • A path with a leading dot (such as ".x") raises TypeError from importlib instead of the documented ValueError. Please catch it in _resolve.
  • from_dict imports any installed module named in its input. A note in the docstring that it is for trusted input only would help.
  • Result.status isn't validated. I understand this is deferred on purpose, so it's fine to leave.

Reviewed commit 52eb8e7


status: Literal["success", "failed"]
metrics: Mapping[str, Any]
metadata: Mapping[str, Any] = field(default_factory=dict)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

can you explicitly add an artifact field for this? this is generally useful for saving checkpoints that can be reused in other benchmark.

@fbonc fbonc Oct 29, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

added artifact: Path | None to Result, for an evaluation's reusable output, e.g. a checkpoint:

  • single field so theres no key naming to agree on
  • can point to a folder if a checkpoint is several files/ there are multiple artifacts

This branch has not been deployed

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

Labels

status: manual Pending maintainer's review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants