meds-algorithm 0.4.0a4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- meds_algorithm/ARCHITECTURE.md +376 -0
- meds_algorithm/CONTRACT.md +567 -0
- meds_algorithm/__init__.py +94 -0
- meds_algorithm/algorithms.py +157 -0
- meds_algorithm/artifacts/__init__.py +1 -0
- meds_algorithm/artifacts/inputs.py +38 -0
- meds_algorithm/artifacts/manifest.py +142 -0
- meds_algorithm/artifacts/publication.py +68 -0
- meds_algorithm/artifacts/references.py +79 -0
- meds_algorithm/artifacts/results.py +76 -0
- meds_algorithm/configuration.py +64 -0
- meds_algorithm/conformance/__init__.py +1 -0
- meds_algorithm/conformance/assertions.py +62 -0
- meds_algorithm/conformance/end_to_end.py +150 -0
- meds_algorithm/conformance/meds_dev.py +127 -0
- meds_algorithm/conformance/reports.py +49 -0
- meds_algorithm/conformance/signal.py +141 -0
- meds_algorithm/conformance/types.py +29 -0
- meds_algorithm/contracts/__init__.py +1 -0
- meds_algorithm/contracts/commands.py +182 -0
- meds_algorithm/contracts/enums.py +28 -0
- meds_algorithm/data/__init__.py +1 -0
- meds_algorithm/data/index.py +56 -0
- meds_algorithm/data/labels.py +55 -0
- meds_algorithm/data/predicates.py +60 -0
- meds_algorithm/data/validation.py +25 -0
- meds_algorithm/evaluation/__init__.py +1 -0
- meds_algorithm/evaluation/predictions.py +56 -0
- meds_algorithm/integrations/__init__.py +1 -0
- meds_algorithm/integrations/meds_dev.py +51 -0
- meds_algorithm/requests.py +262 -0
- meds_algorithm/runner.py +232 -0
- meds_algorithm/runtime/__init__.py +1 -0
- meds_algorithm/runtime/process.py +50 -0
- meds_algorithm/runtime/provenance.py +31 -0
- meds_algorithm/runtime/resources.py +97 -0
- meds_algorithm/serialization.py +122 -0
- meds_algorithm/spec.py +23 -0
- meds_algorithm/tasks/__init__.py +1 -0
- meds_algorithm/tasks/spec.py +136 -0
- meds_algorithm/version.py +3 -0
- meds_algorithm-0.4.0a4.dist-info/METADATA +212 -0
- meds_algorithm-0.4.0a4.dist-info/RECORD +46 -0
- meds_algorithm-0.4.0a4.dist-info/WHEEL +4 -0
- meds_algorithm-0.4.0a4.dist-info/entry_points.txt +4 -0
- meds_algorithm-0.4.0a4.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Algorithm-owned configuration types and shared per-invocation execution options."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
from .serialization import Serializable
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class AlgorithmConfig(Serializable):
|
|
13
|
+
"""Parent for algorithm-owned typed configuration, defaults and validation."""
|
|
14
|
+
|
|
15
|
+
additional_predicates_file: Path | None = field(default=None, kw_only=True)
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def from_file(cls, path: Path | str):
|
|
19
|
+
"""Read a user JSON/YAML values file, or an exact serialized config.json envelope."""
|
|
20
|
+
path = Path(path).resolve()
|
|
21
|
+
values = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
22
|
+
if not isinstance(values, dict):
|
|
23
|
+
raise TypeError("Configuration file must contain an object")
|
|
24
|
+
if set(values) == {"schema_version", "type", "values"}:
|
|
25
|
+
return cls.load(path)
|
|
26
|
+
return cls.from_dict(values, base=path.parent)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, kw_only=True)
|
|
30
|
+
class NNConfig(AlgorithmConfig):
|
|
31
|
+
"""Framework-independent neural-network settings; concrete algorithms supply training defaults.
|
|
32
|
+
|
|
33
|
+
batch_size is examples per device per microbatch, before gradient accumulation.
|
|
34
|
+
Subclasses overriding validate() must call super().validate().
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
capacity: float = 1.0
|
|
38
|
+
learning_rate: float
|
|
39
|
+
batch_size: int
|
|
40
|
+
weight_decay: float = 0.0
|
|
41
|
+
dropout: float = 0.0
|
|
42
|
+
|
|
43
|
+
def validate(self):
|
|
44
|
+
super().validate()
|
|
45
|
+
if self.capacity <= 0:
|
|
46
|
+
raise ValueError("capacity must be positive")
|
|
47
|
+
if self.learning_rate <= 0:
|
|
48
|
+
raise ValueError("learning_rate must be positive")
|
|
49
|
+
if self.batch_size < 1:
|
|
50
|
+
raise ValueError("batch_size must be positive")
|
|
51
|
+
if self.weight_decay < 0:
|
|
52
|
+
raise ValueError("weight_decay must be nonnegative")
|
|
53
|
+
if not 0 <= self.dropout < 1:
|
|
54
|
+
raise ValueError("dropout must be in [0, 1)")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class ExecutionOptions(Serializable):
|
|
59
|
+
seed: int = 0
|
|
60
|
+
overwrite: bool = False
|
|
61
|
+
|
|
62
|
+
def validate(self):
|
|
63
|
+
if self.seed < 0:
|
|
64
|
+
raise ValueError("seed must be nonnegative")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Reusable assertions, synthetic fixtures and explicitly opted-in integration provisioning."""
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from ..data.index import read_index
|
|
4
|
+
from ..evaluation.predictions import check_decoded
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def read_predictions(predictions_dir: Path, *, require_truth: bool = True):
|
|
8
|
+
"""Read every prediction shard, validating each shard's schema and probability values."""
|
|
9
|
+
import polars as pl
|
|
10
|
+
|
|
11
|
+
paths = sorted(Path(predictions_dir).rglob("*.parquet"))
|
|
12
|
+
if not paths:
|
|
13
|
+
raise FileNotFoundError(f"No prediction parquet files under {predictions_dir}")
|
|
14
|
+
frames = []
|
|
15
|
+
for path in paths:
|
|
16
|
+
frame = pl.read_parquet(path)
|
|
17
|
+
if require_truth:
|
|
18
|
+
assert_prediction_schema(frame)
|
|
19
|
+
check_decoded(frame.select("subject_id", "prediction_time"), frame)
|
|
20
|
+
frames.append(frame)
|
|
21
|
+
combined = pl.concat(frames, how="diagonal_relaxed")
|
|
22
|
+
check_decoded(combined.select("subject_id", "prediction_time"), combined)
|
|
23
|
+
return combined
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def assert_prediction_keys(predictions_dir: Path, labels_dir: Path, splits) -> None:
|
|
27
|
+
"""Compare actual prediction keys with requested keys, independent of row order."""
|
|
28
|
+
import polars as pl
|
|
29
|
+
|
|
30
|
+
key_columns = ["subject_id", "prediction_time"]
|
|
31
|
+
|
|
32
|
+
# MEDS-DEV evaluates every `{predictions_dir}/**/*.parquet` (configs/_evaluate_predictions.yaml), so
|
|
33
|
+
# every parquet under the predictions artifact must be a predictions file. Publish supporting tables
|
|
34
|
+
# (e.g. a zero-shot query bundle) in another format, such as Arrow IPC.
|
|
35
|
+
for path in sorted(Path(predictions_dir).rglob("*.parquet")):
|
|
36
|
+
schema = pl.read_parquet_schema(path)
|
|
37
|
+
if "predicted_boolean_probability" not in schema and "predicted_boolean_value" not in schema:
|
|
38
|
+
raise AssertionError(
|
|
39
|
+
f"{path} is not a predictions file; MEDS-DEV evaluates every parquet under the predictions "
|
|
40
|
+
"directory; store supporting tables in another format (e.g. Arrow IPC)"
|
|
41
|
+
)
|
|
42
|
+
try:
|
|
43
|
+
predictions = read_index(Path(predictions_dir))
|
|
44
|
+
expected = read_index(Path(labels_dir), splits)
|
|
45
|
+
except ValueError as error:
|
|
46
|
+
raise AssertionError(str(error)) from error
|
|
47
|
+
prediction_unique = predictions.unique()
|
|
48
|
+
expected_unique = expected.unique()
|
|
49
|
+
assert prediction_unique.height == predictions.height, "predictions contain duplicate evaluation keys"
|
|
50
|
+
assert expected_unique.height == expected.height, "requested evaluation index contains duplicate keys"
|
|
51
|
+
missing = expected_unique.join(prediction_unique, on=key_columns, how="anti")
|
|
52
|
+
extra = prediction_unique.join(expected_unique, on=key_columns, how="anti")
|
|
53
|
+
assert missing.is_empty() and extra.is_empty(), (
|
|
54
|
+
f"prediction key mismatch: {missing.height} missing and {extra.height} extra keys"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def assert_prediction_schema(predictions) -> None:
|
|
59
|
+
"""Validate predictions exactly as MEDS-DEV's pinned meds-evaluation (0.0.3) will."""
|
|
60
|
+
from meds_evaluation.schema import validate_binary_classification_schema
|
|
61
|
+
|
|
62
|
+
validate_binary_classification_schema(predictions)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Reusable end-to-end conformance; execution and fitted-feature inspection are algorithm-owned."""
|
|
2
|
+
|
|
3
|
+
from contextlib import suppress
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from meds_algorithm import (
|
|
7
|
+
ALGORITHM_PROFILES,
|
|
8
|
+
STEP_SPECS,
|
|
9
|
+
ZERO_SHOT_TASK_COMMANDS,
|
|
10
|
+
CommandName,
|
|
11
|
+
RelaxationLedger,
|
|
12
|
+
TaskSpec,
|
|
13
|
+
read_manifest,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from .assertions import assert_prediction_keys, read_predictions
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _strip_labels(labels_dir: Path, destination: Path, splits) -> Path:
|
|
20
|
+
"""Remove outcomes from selected splits while retaining permitted training/context outcomes."""
|
|
21
|
+
import polars as pl
|
|
22
|
+
|
|
23
|
+
from meds_algorithm import split_files
|
|
24
|
+
|
|
25
|
+
labels_dir = Path(labels_dir)
|
|
26
|
+
files = split_files(labels_dir)
|
|
27
|
+
stripped = set(files) if splits is None else set()
|
|
28
|
+
for split in splits or ():
|
|
29
|
+
# A separate training/context root need not contain any evaluated split.
|
|
30
|
+
with suppress(FileNotFoundError):
|
|
31
|
+
stripped.update(split_files(labels_dir, [split]))
|
|
32
|
+
for path in split_files(labels_dir):
|
|
33
|
+
target = destination / (path.relative_to(labels_dir) if Path(labels_dir).is_dir() else path.name)
|
|
34
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
if path in stripped:
|
|
36
|
+
pl.read_parquet(path, columns=["subject_id", "prediction_time"]).write_parquet(target)
|
|
37
|
+
else:
|
|
38
|
+
pl.read_parquet(path).write_parquet(target)
|
|
39
|
+
return destination
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def assert_profile_run(result, *, profile, run_e2e, fitted_codes, tmp_path):
|
|
43
|
+
"""Check actual artifacts, task binding, key coverage, prediction reads and split canaries."""
|
|
44
|
+
artifacts = result.artifacts
|
|
45
|
+
assert list(artifacts) == [command.value for command in ALGORITHM_PROFILES[profile]]
|
|
46
|
+
for command, directory in artifacts.items():
|
|
47
|
+
manifest = read_manifest(directory)
|
|
48
|
+
assert manifest["command"]["name"] == command
|
|
49
|
+
assert manifest["artifact"]["type"] == STEP_SPECS[CommandName(command)].artifact_type.value
|
|
50
|
+
assert str(manifest["command"]["config_digest"]).startswith("sha256:"), (
|
|
51
|
+
f"{command} has no config digest"
|
|
52
|
+
)
|
|
53
|
+
resources = manifest["resources"]
|
|
54
|
+
assert resources["wall_seconds"] >= 0
|
|
55
|
+
memory = resources["memory"]
|
|
56
|
+
if memory["source"] == "unavailable":
|
|
57
|
+
assert memory["peak_process_tree_rss_bytes"] is None
|
|
58
|
+
else:
|
|
59
|
+
assert memory["peak_process_tree_rss_bytes"] > 0
|
|
60
|
+
prediction_step = ALGORITHM_PROFILES[profile][-1].value
|
|
61
|
+
read_predictions(artifacts[prediction_step])
|
|
62
|
+
prediction_manifest = read_manifest(artifacts[prediction_step])
|
|
63
|
+
assert prediction_manifest["artifact"]["type"] == "predictions"
|
|
64
|
+
assert_prediction_keys(artifacts[prediction_step], result.labels_dir, result.splits)
|
|
65
|
+
|
|
66
|
+
assert_label_blind_pipeline(result, profile=profile, run_e2e=run_e2e, tmp_path=tmp_path)
|
|
67
|
+
|
|
68
|
+
# Absence of transductive fitting (SPEC 2.1): codes that occur only in tuning or held-out subjects must
|
|
69
|
+
# not be part of anything the algorithm fitted.
|
|
70
|
+
assert set(result.canaries) == {"tuning", "held_out"}, (
|
|
71
|
+
"run_e2e must apply core conformance.signal.add_split_canaries and return the canaries"
|
|
72
|
+
)
|
|
73
|
+
leaked = set(result.canaries.values()) & set(fitted_codes(artifacts))
|
|
74
|
+
assert not leaked, f"statistics were fitted over non-training splits: {sorted(leaked)}"
|
|
75
|
+
|
|
76
|
+
# Zero-shot: every task-conditioned artifact names its task, and a ledger is published (SPEC 2.4, 3.1).
|
|
77
|
+
if not profile.startswith("zero_shot_"):
|
|
78
|
+
return
|
|
79
|
+
task = TaskSpec.from_arguments(result.inputs)
|
|
80
|
+
for command in ZERO_SHOT_TASK_COMMANDS.get(profile, ()):
|
|
81
|
+
manifest = read_manifest(artifacts[command.value])
|
|
82
|
+
assert manifest["semantics_digest"] == task.semantics_digest(), (
|
|
83
|
+
f"{command.value} is not bound to the task"
|
|
84
|
+
)
|
|
85
|
+
assert manifest["inputs"]["task"]["criteria_file"]["digest"].startswith("sha256:")
|
|
86
|
+
ledgers = [path.parent for d in artifacts.values() for path in Path(d).rglob("relaxations.yaml")]
|
|
87
|
+
assert ledgers, "every zero-shot run must publish relaxations.yaml"
|
|
88
|
+
for directory in ledgers:
|
|
89
|
+
RelaxationLedger.read(directory) # raises unless query_language, decoded_quantity, and exact are set
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def assert_label_blind_pipeline(result, *, profile, run_e2e, tmp_path):
|
|
93
|
+
"""Rerun every stage on the same histories with forbidden outcomes absent and fresh artifacts."""
|
|
94
|
+
assert result.inputs.get("external_meds_dir"), "E2EResult.inputs must record external_meds_dir"
|
|
95
|
+
assert type(result.inputs.get("seed")) is int, "E2EResult.inputs must record the run_e2e seed"
|
|
96
|
+
expected_steps = [step.value for step in ALGORITHM_PROFILES[profile]]
|
|
97
|
+
original = read_predictions(result.artifacts[expected_steps[-1]])
|
|
98
|
+
overrides = dict(result.inputs)
|
|
99
|
+
for argument in ("external_labels_dir", "external_index_dir", "external_evaluation_labels_dir"):
|
|
100
|
+
overrides[argument] = _strip_labels(
|
|
101
|
+
result.inputs.get(argument) or result.labels_dir,
|
|
102
|
+
tmp_path / "unlabelled-inputs" / argument,
|
|
103
|
+
None if profile.startswith("zero_shot_") else result.splits,
|
|
104
|
+
)
|
|
105
|
+
fresh_root = tmp_path / "label-blind-run"
|
|
106
|
+
blind = run_e2e(
|
|
107
|
+
fresh_root,
|
|
108
|
+
profile=profile,
|
|
109
|
+
**{
|
|
110
|
+
**overrides,
|
|
111
|
+
"splits": result.splits,
|
|
112
|
+
},
|
|
113
|
+
)
|
|
114
|
+
assert list(blind.artifacts) == expected_steps, "label-blind check must execute the full lifecycle"
|
|
115
|
+
old_paths = {Path(path).resolve() for path in result.artifacts.values()}
|
|
116
|
+
for step, path in blind.artifacts.items():
|
|
117
|
+
path = Path(path).resolve()
|
|
118
|
+
assert path not in old_paths and path.is_relative_to(fresh_root.resolve()), (
|
|
119
|
+
"label-blind check must publish fresh outputs inside its run directory"
|
|
120
|
+
)
|
|
121
|
+
manifest = read_manifest(path)
|
|
122
|
+
assert manifest["command"]["name"] == step
|
|
123
|
+
assert manifest["artifact"]["type"] == STEP_SPECS[CommandName(step)].artifact_type.value
|
|
124
|
+
prediction = blind.artifacts[expected_steps[-1]]
|
|
125
|
+
assert_prediction_keys(prediction, result.labels_dir, result.splits)
|
|
126
|
+
# Do not require the evaluation-only boolean_value on a deliberately unlabelled run.
|
|
127
|
+
import polars as pl
|
|
128
|
+
from polars.testing import assert_frame_equal
|
|
129
|
+
|
|
130
|
+
from ..data.index import read_index
|
|
131
|
+
from ..evaluation.predictions import check_decoded
|
|
132
|
+
|
|
133
|
+
blind_predictions = read_predictions(prediction, require_truth=False)
|
|
134
|
+
check_decoded(read_index(result.labels_dir, result.splits), blind_predictions)
|
|
135
|
+
columns = ["subject_id", "prediction_time", "predicted_boolean_probability"]
|
|
136
|
+
if "predicted_boolean_value" in original.columns:
|
|
137
|
+
columns.append("predicted_boolean_value")
|
|
138
|
+
try:
|
|
139
|
+
assert_frame_equal(
|
|
140
|
+
original.select(columns).sort(columns[:2]),
|
|
141
|
+
blind_predictions.select(columns).sort(columns[:2]),
|
|
142
|
+
check_exact=False,
|
|
143
|
+
rel_tol=1e-6,
|
|
144
|
+
abs_tol=1e-8,
|
|
145
|
+
)
|
|
146
|
+
except (AssertionError, pl.exceptions.ColumnNotFoundError) as error:
|
|
147
|
+
raise AssertionError(
|
|
148
|
+
"Predictions changed when forbidden labels were removed; fix leakage or make the "
|
|
149
|
+
"conformance fixture reproducible with the recorded seed"
|
|
150
|
+
) from error
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Shared opt-in integration-test utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from ..runtime.process import run
|
|
13
|
+
from .assertions import assert_prediction_keys as assert_prediction_keys
|
|
14
|
+
from .assertions import assert_prediction_schema as assert_prediction_schema
|
|
15
|
+
|
|
16
|
+
MEDS_DEV_URL = "https://github.com/Medical-Event-Data-Standard/MEDS-DEV.git"
|
|
17
|
+
# Head of MEDS-DEV PR #330 (task_criteria_path), on top of dev, which contains PR #325 (predicates_path).
|
|
18
|
+
# Repin to the merge commit once #330 merges: a branch commit can become unreachable after a squash merge.
|
|
19
|
+
MEDS_DEV_PINNED_REF = "19a8aa3417fae3586b2e8f0f17aba19ddb0560d4"
|
|
20
|
+
INSTALL_TIMEOUT = 1800
|
|
21
|
+
RUN_TIMEOUT = 5400
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def venv_bin(checkout: Path) -> Path:
|
|
25
|
+
return checkout / ".venv" / ("Scripts" if sys.platform == "win32" else "bin")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def assert_meds_dev_predicates_capability(checkout: Path) -> None:
|
|
29
|
+
"""Require the explicit predicates_path passthrough proposed in MEDS-DEV PR #325."""
|
|
30
|
+
config = checkout / "src" / "MEDS_DEV" / "configs" / "_run_model.yaml"
|
|
31
|
+
implementation = checkout / "src" / "MEDS_DEV" / "models" / "__init__.py"
|
|
32
|
+
if not config.is_file() or not implementation.is_file():
|
|
33
|
+
raise AssertionError(f"{checkout} is not a MEDS-DEV source checkout")
|
|
34
|
+
if (
|
|
35
|
+
"predicates_path:" not in config.read_text()
|
|
36
|
+
or 'format_kwargs["predicates_path"]' not in implementation.read_text()
|
|
37
|
+
):
|
|
38
|
+
raise AssertionError(
|
|
39
|
+
"MEDS-DEV lacks the predicates_path algorithm-command passthrough. Use MEDS_DEV_REF="
|
|
40
|
+
f"{MEDS_DEV_PINNED_REF} or a revision containing MEDS-DEV PR #325."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def assert_meds_dev_task_criteria_capability(checkout: Path) -> None:
|
|
45
|
+
"""Require the task_criteria_path passthrough proposed in MEDS-DEV PR #330 (zero-shot profiles)."""
|
|
46
|
+
implementation = checkout / "src" / "MEDS_DEV" / "models" / "__init__.py"
|
|
47
|
+
if 'format_kwargs["task_criteria_path"]' not in implementation.read_text():
|
|
48
|
+
raise AssertionError(
|
|
49
|
+
"MEDS-DEV lacks the task_criteria_path algorithm-command passthrough that zero-shot model.yaml "
|
|
50
|
+
f"commands use. Use MEDS_DEV_REF={MEDS_DEV_PINNED_REF} or a revision containing MEDS-DEV PR #330."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def provision_meds_dev(request, tmp_path_factory, *, task_criteria: bool = False) -> Path:
|
|
55
|
+
source = os.environ.get("MEDS_DEV_DIR")
|
|
56
|
+
expression = request.config.getoption("markexpr", default="") or ""
|
|
57
|
+
from _pytest.mark.expression import Expression
|
|
58
|
+
|
|
59
|
+
selected = False
|
|
60
|
+
if expression.strip():
|
|
61
|
+
marker_expression = Expression.compile(expression)
|
|
62
|
+
selected = not marker_expression.evaluate(lambda name, **kwargs: name == "slow") and any(
|
|
63
|
+
marker_expression.evaluate(lambda name, marker=marker, **kwargs: name in {marker, "slow"})
|
|
64
|
+
for marker in ("meds_dev", "real_data")
|
|
65
|
+
)
|
|
66
|
+
if not selected:
|
|
67
|
+
pytest.skip("explicitly select -m meds_dev or -m real_data; MEDS_DEV_DIR alone does not opt in")
|
|
68
|
+
uv = shutil.which("uv")
|
|
69
|
+
git = shutil.which("git")
|
|
70
|
+
if uv is None or git is None:
|
|
71
|
+
pytest.skip("uv and git are required for MEDS-DEV integration")
|
|
72
|
+
checkout = tmp_path_factory.mktemp("meds-dev") / "MEDS-DEV"
|
|
73
|
+
run([git, "clone", source or MEDS_DEV_URL, checkout], timeout=INSTALL_TIMEOUT)
|
|
74
|
+
if source is None:
|
|
75
|
+
ref = os.environ.get("MEDS_DEV_REF", MEDS_DEV_PINNED_REF)
|
|
76
|
+
run([git, "-C", checkout, "checkout", "--detach", ref], timeout=INSTALL_TIMEOUT)
|
|
77
|
+
assert_meds_dev_predicates_capability(checkout)
|
|
78
|
+
if task_criteria:
|
|
79
|
+
assert_meds_dev_task_criteria_capability(checkout)
|
|
80
|
+
run([uv, "venv", checkout / ".venv"], timeout=INSTALL_TIMEOUT)
|
|
81
|
+
run(
|
|
82
|
+
[uv, "pip", "install", "-e", checkout, "--python", venv_bin(checkout) / "python"],
|
|
83
|
+
timeout=INSTALL_TIMEOUT,
|
|
84
|
+
env={"SETUPTOOLS_SCM_PRETEND_VERSION_FOR_MEDS_DEV": "0.0.0"},
|
|
85
|
+
)
|
|
86
|
+
return checkout
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def prepare_mimic_demo(meds_dev, tmp_path):
|
|
90
|
+
"""Return MEDS-DEV's ``(MIMIC demo, task labels, MIMIC predicates)`` paths.
|
|
91
|
+
|
|
92
|
+
Use ``MEDS_DEMO_DIR`` when set; otherwise invoke MEDS-DEV's own demo builder. Pin an existing
|
|
93
|
+
demo-compatible MEDS-DEV task rather than defining a task in this repository.
|
|
94
|
+
"""
|
|
95
|
+
configured = os.environ.get("MEDS_DEMO_DIR")
|
|
96
|
+
dataset_dir = Path(configured).resolve() if configured else tmp_path / "mimic-iv-demo"
|
|
97
|
+
if not configured:
|
|
98
|
+
run(
|
|
99
|
+
[
|
|
100
|
+
venv_bin(meds_dev) / "meds-dev-dataset",
|
|
101
|
+
"dataset=MIMIC-IV",
|
|
102
|
+
f"output_dir={dataset_dir}",
|
|
103
|
+
"demo=true",
|
|
104
|
+
],
|
|
105
|
+
timeout=RUN_TIMEOUT,
|
|
106
|
+
)
|
|
107
|
+
labels_dir = tmp_path / "mortality-in-icu-first-24h"
|
|
108
|
+
run(
|
|
109
|
+
[
|
|
110
|
+
venv_bin(meds_dev) / "meds-dev-task",
|
|
111
|
+
"task=mortality/in_icu/first_24h",
|
|
112
|
+
"dataset=MIMIC-IV",
|
|
113
|
+
f"dataset_dir={dataset_dir}",
|
|
114
|
+
f"output_dir={labels_dir}",
|
|
115
|
+
],
|
|
116
|
+
# MEDS-DEV's task runner launches venv-installed helpers by bare name.
|
|
117
|
+
# Prepend the pinned checkout so an ambient MEDS-DEV install cannot win.
|
|
118
|
+
env={"PATH": f"{venv_bin(meds_dev)}{os.pathsep}{os.environ['PATH']}"},
|
|
119
|
+
timeout=RUN_TIMEOUT,
|
|
120
|
+
)
|
|
121
|
+
predicates_file = meds_dev / "src" / "MEDS_DEV" / "datasets" / "MIMIC-IV" / "predicates.yaml"
|
|
122
|
+
return dataset_dir, labels_dir, predicates_file
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def mimic_task_criteria(meds_dev) -> Path:
|
|
126
|
+
"""MEDS-DEV's own ACES criteria for the demo task: what a zero-shot algorithm is asked to predict."""
|
|
127
|
+
return meds_dev / "src" / "MEDS_DEV" / "tasks" / "mortality" / "in_icu" / "first_24h.yaml"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Completion evidence shared by generated repositories."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _sections(text, level):
|
|
7
|
+
parts = re.split(rf"^{'#' * level} (.+)\s*$", text, flags=re.MULTILINE)
|
|
8
|
+
return dict(zip(parts[1::2], parts[2::2], strict=True))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def assert_implementation_report(report: str, profiles, stub_profiles) -> None:
|
|
12
|
+
"""Validate shared evidence and each completed profile, allowing unfinished profile sections."""
|
|
13
|
+
assert set(stub_profiles) <= set(profiles), "Unknown profile in STUB_PROFILES"
|
|
14
|
+
complete = set(profiles) - set(stub_profiles)
|
|
15
|
+
if not complete:
|
|
16
|
+
return
|
|
17
|
+
sections = _sections(report, 2)
|
|
18
|
+
required = {
|
|
19
|
+
"Source",
|
|
20
|
+
"Source-element ledger",
|
|
21
|
+
"Inputs and predicates",
|
|
22
|
+
"Intermediate artifacts",
|
|
23
|
+
"Scientific deviations",
|
|
24
|
+
"Verification",
|
|
25
|
+
"Review gates",
|
|
26
|
+
"Resource measurements",
|
|
27
|
+
"Environment and provenance",
|
|
28
|
+
}
|
|
29
|
+
assert required <= sections.keys(), f"Missing report sections: {sorted(required - sections.keys())}"
|
|
30
|
+
choices = _sections(sections.get("Profile choices", ""), 3)
|
|
31
|
+
assert complete <= choices.keys(), f"Missing profile reports: {sorted(complete - choices.keys())}"
|
|
32
|
+
inactive_sections = {
|
|
33
|
+
section
|
|
34
|
+
for section, profile in {
|
|
35
|
+
"Zero-shot task translation": "zero_shot_direct",
|
|
36
|
+
"Zero-shot materialization": "zero_shot_materialized",
|
|
37
|
+
}.items()
|
|
38
|
+
if profile not in complete
|
|
39
|
+
}
|
|
40
|
+
checked = {
|
|
41
|
+
name: body
|
|
42
|
+
for name, body in sections.items()
|
|
43
|
+
if name != "Profile choices" and name not in inactive_sections
|
|
44
|
+
}
|
|
45
|
+
checked.update({f"Profile {profile}": choices[profile] for profile in complete})
|
|
46
|
+
for name, body in checked.items():
|
|
47
|
+
assert body.strip() and "TODO" not in body and "<paper/repository not yet supplied>" not in body, (
|
|
48
|
+
f"Incomplete implementation report: {name}"
|
|
49
|
+
)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Small example MEDS cohort for a algorithm-owned primary-behavior test."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import random
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import polars as pl
|
|
11
|
+
|
|
12
|
+
SIGNAL_CODE = "SIGNAL//POS"
|
|
13
|
+
BACKGROUND_CODES = [f"BG//{index}" for index in range(6)]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_signal_dataset(
|
|
17
|
+
root: Path,
|
|
18
|
+
*,
|
|
19
|
+
n_train: int = 200,
|
|
20
|
+
n_tuning: int = 40,
|
|
21
|
+
n_held_out: int = 40,
|
|
22
|
+
seed: int = 0,
|
|
23
|
+
shuffle_labels: bool = False,
|
|
24
|
+
signal_code: str = SIGNAL_CODE,
|
|
25
|
+
background_codes: list[str] | None = None,
|
|
26
|
+
) -> Path:
|
|
27
|
+
"""Write a tiny MEDS dataset where ``signal_code`` alone determines the binary label.
|
|
28
|
+
|
|
29
|
+
Positive and negative subjects have equal-length histories. The signal position and history length
|
|
30
|
+
are randomized independently of the label, so a algorithm must represent the code rather than exploit a
|
|
31
|
+
length or position leak. Shuffling labels supplies the paired negative control.
|
|
32
|
+
|
|
33
|
+
This is an example fixture, not an input representation imposed on the algorithm. Adapt or replace it
|
|
34
|
+
when the implementation needs codes from a fixed vocabulary, predicates, numeric values, or another
|
|
35
|
+
algorithm-specific binding.
|
|
36
|
+
"""
|
|
37
|
+
root = Path(root)
|
|
38
|
+
rng = random.Random(seed)
|
|
39
|
+
background = list(background_codes or BACKGROUND_CODES)
|
|
40
|
+
if not background:
|
|
41
|
+
raise ValueError("background_codes must contain at least one code")
|
|
42
|
+
|
|
43
|
+
split_sizes = {"train": n_train, "tuning": n_tuning, "held_out": n_held_out}
|
|
44
|
+
subject_splits: list[dict] = []
|
|
45
|
+
subject_id = 1
|
|
46
|
+
base_time = datetime(2020, 1, 1)
|
|
47
|
+
|
|
48
|
+
for split, size in split_sizes.items():
|
|
49
|
+
events: list[dict] = []
|
|
50
|
+
labels: list[dict] = []
|
|
51
|
+
for _ in range(size):
|
|
52
|
+
positive = rng.random() < 0.5
|
|
53
|
+
start = base_time + timedelta(days=rng.randrange(100))
|
|
54
|
+
sequence = [rng.choice(background) for _ in range(rng.randint(4, 9))]
|
|
55
|
+
marker_position = rng.randrange(len(sequence) + 1)
|
|
56
|
+
marker = signal_code if positive else rng.choice(background)
|
|
57
|
+
sequence.insert(marker_position, marker)
|
|
58
|
+
|
|
59
|
+
for offset, code in enumerate(sequence):
|
|
60
|
+
events.append(
|
|
61
|
+
{
|
|
62
|
+
"subject_id": subject_id,
|
|
63
|
+
"time": start + timedelta(hours=offset),
|
|
64
|
+
"code": code,
|
|
65
|
+
"numeric_value": None,
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
labels.append(
|
|
69
|
+
{
|
|
70
|
+
"subject_id": subject_id,
|
|
71
|
+
"prediction_time": start + timedelta(hours=len(sequence) + 1),
|
|
72
|
+
"boolean_value": positive,
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
subject_splits.append({"subject_id": subject_id, "split": split})
|
|
76
|
+
subject_id += 1
|
|
77
|
+
|
|
78
|
+
data_dir = root / "data" / split
|
|
79
|
+
labels_dir = root / "task_labels" / "signal_task"
|
|
80
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
labels_dir.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
pl.DataFrame(events).with_columns(
|
|
83
|
+
pl.col("subject_id").cast(pl.Int64),
|
|
84
|
+
pl.col("time").cast(pl.Datetime("us")),
|
|
85
|
+
pl.col("code").cast(pl.String),
|
|
86
|
+
pl.col("numeric_value").cast(pl.Float32),
|
|
87
|
+
).write_parquet(data_dir / "0.parquet")
|
|
88
|
+
label_frame = pl.DataFrame(labels).with_columns(
|
|
89
|
+
pl.col("subject_id").cast(pl.Int64),
|
|
90
|
+
pl.col("prediction_time").cast(pl.Datetime("us")),
|
|
91
|
+
pl.col("boolean_value").cast(pl.Boolean),
|
|
92
|
+
)
|
|
93
|
+
if shuffle_labels:
|
|
94
|
+
label_frame = label_frame.with_columns(
|
|
95
|
+
pl.col("boolean_value").shuffle(seed=seed + 1).alias("boolean_value")
|
|
96
|
+
)
|
|
97
|
+
label_frame.write_parquet(labels_dir / f"{split}.parquet")
|
|
98
|
+
|
|
99
|
+
metadata = root / "metadata"
|
|
100
|
+
metadata.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
pl.DataFrame({"code": [*background, signal_code]}).write_parquet(metadata / "codes.parquet")
|
|
102
|
+
pl.DataFrame(subject_splits).with_columns(pl.col("subject_id").cast(pl.Int64)).write_parquet(
|
|
103
|
+
metadata / "subject_splits.parquet"
|
|
104
|
+
)
|
|
105
|
+
(metadata / "dataset.json").write_text(
|
|
106
|
+
json.dumps({"dataset_name": "synthetic_signal", "meds_version": "0.4"})
|
|
107
|
+
)
|
|
108
|
+
return root
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
CANARY_CODES = {"tuning": "CANARY//tuning", "held_out": "CANARY//held_out"}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def add_split_canaries(root: Path) -> dict[str, str]:
|
|
115
|
+
"""Give every tuning and held-out subject one event with a code that occurs in no other split.
|
|
116
|
+
|
|
117
|
+
Statistics fitted on the training split alone (vocabularies, code frequencies, bins) can never contain
|
|
118
|
+
these codes; a algorithm that fits over every split will. The codes are not added to
|
|
119
|
+
``metadata/codes.parquet``, so a vocabulary read from metadata does not pick them up either. Each canary
|
|
120
|
+
is placed at the subject's first event time, so it falls in history before any prediction time.
|
|
121
|
+
Returns the canary code of each split.
|
|
122
|
+
"""
|
|
123
|
+
root = Path(root)
|
|
124
|
+
for split, code in CANARY_CODES.items():
|
|
125
|
+
for shard in sorted((root / "data" / split).glob("*.parquet")):
|
|
126
|
+
events = pl.read_parquet(shard)
|
|
127
|
+
first = (
|
|
128
|
+
events.filter(pl.col("time").is_not_null())
|
|
129
|
+
.group_by("subject_id")
|
|
130
|
+
.agg(pl.col("time").min())
|
|
131
|
+
.with_columns(pl.lit(code).alias("code"))
|
|
132
|
+
)
|
|
133
|
+
# Every other column (numeric_value, text_value, ...) is null on the canary events.
|
|
134
|
+
canaries = first.with_columns(
|
|
135
|
+
pl.lit(None, dtype=dtype).alias(column)
|
|
136
|
+
for column, dtype in events.schema.items()
|
|
137
|
+
if column not in first.columns
|
|
138
|
+
)
|
|
139
|
+
canaries = canaries.select(events.columns).cast(events.schema)
|
|
140
|
+
pl.concat([events, canaries]).sort("subject_id", "time", nulls_last=False).write_parquet(shard)
|
|
141
|
+
return dict(CANARY_CODES)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from collections.abc import Mapping, Sequence
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class E2EResult:
|
|
9
|
+
"""Artifacts plus the requested evaluation index used to verify prediction keys."""
|
|
10
|
+
|
|
11
|
+
artifacts: Mapping[str, Path]
|
|
12
|
+
labels_dir: Path
|
|
13
|
+
splits: Sequence[str]
|
|
14
|
+
# External inputs and the integer seed honored by run_e2e.
|
|
15
|
+
# Zero-shot profiles MUST include external_task_criteria_file
|
|
16
|
+
# (and external_predicates_file when used) so tests can check which task each artifact answers.
|
|
17
|
+
inputs: Mapping[str, Any] = field(default_factory=dict)
|
|
18
|
+
# Codes added by conformance.signal.add_split_canaries to tuning and held-out subjects.
|
|
19
|
+
canaries: Mapping[str, str] = field(default_factory=dict)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class MEDSDevRun:
|
|
24
|
+
"""MEDS-DEV invocation plus the evaluation index its predictions must cover."""
|
|
25
|
+
|
|
26
|
+
args: Sequence[str]
|
|
27
|
+
predictions_dir: Path
|
|
28
|
+
labels_dir: Path
|
|
29
|
+
splits: Sequence[str]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Stable command, profile and artifact-type definitions."""
|