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,182 @@
|
|
|
1
|
+
"""Derived legacy contract views and compatibility dispatch.
|
|
2
|
+
|
|
3
|
+
Profile ABCs own lifecycle order; typed requests own argument definitions. These views keep existing
|
|
4
|
+
manifest readers and callers working without maintaining a second specification.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from collections.abc import Mapping, Sequence
|
|
11
|
+
from dataclasses import MISSING, dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from types import MappingProxyType
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from ..algorithms import profile_classes, request_type
|
|
17
|
+
from ..artifacts.references import ArtifactReference
|
|
18
|
+
from ..requests import PredictionIndex, TrainingLabels
|
|
19
|
+
from ..tasks.spec import TaskSpec
|
|
20
|
+
from .enums import ArtifactType as ArtifactType
|
|
21
|
+
from .enums import CommandName as CommandName
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class ArtifactInput:
|
|
26
|
+
"""Required upstream envelope; payload format/identity checks remain algorithm-owned."""
|
|
27
|
+
|
|
28
|
+
argument: str
|
|
29
|
+
artifact_type: ArtifactType
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class StepSpec:
|
|
34
|
+
"""Executable boundary specification, independent of the computational backend."""
|
|
35
|
+
|
|
36
|
+
artifact_type: ArtifactType
|
|
37
|
+
output_argument: str
|
|
38
|
+
artifact_inputs: tuple[ArtifactInput, ...] = ()
|
|
39
|
+
external_arguments: tuple[str, ...] = ()
|
|
40
|
+
requires_splits: bool = False
|
|
41
|
+
label_access: str = "none"
|
|
42
|
+
task_conditioned: bool = False
|
|
43
|
+
|
|
44
|
+
def validate(self, arguments: Mapping[str, Any]) -> None:
|
|
45
|
+
paths = (
|
|
46
|
+
self.output_argument,
|
|
47
|
+
*self.external_arguments,
|
|
48
|
+
*(item.argument for item in self.artifact_inputs),
|
|
49
|
+
)
|
|
50
|
+
for key in paths:
|
|
51
|
+
value = arguments.get(key)
|
|
52
|
+
if not isinstance(value, (str, Path)) or not str(value).strip():
|
|
53
|
+
raise ValueError(f"requires {key}=<path>")
|
|
54
|
+
if "do_overwrite" in arguments and not isinstance(arguments["do_overwrite"], bool):
|
|
55
|
+
raise ValueError("do_overwrite must be a boolean")
|
|
56
|
+
if self.label_access == "none" and "external_labels_dir" in arguments:
|
|
57
|
+
raise ValueError("use external_index_dir for keys; this step cannot consume training labels")
|
|
58
|
+
splits = arguments.get("splits")
|
|
59
|
+
if (self.requires_splits or splits is not None) and (
|
|
60
|
+
not isinstance(splits, (list, tuple))
|
|
61
|
+
or not splits
|
|
62
|
+
or any(not isinstance(s, str) or not s.strip() for s in splits)
|
|
63
|
+
or len(set(splits)) != len(splits)
|
|
64
|
+
):
|
|
65
|
+
raise ValueError("splits must be an explicit nonempty list of unique split names")
|
|
66
|
+
if (
|
|
67
|
+
not self.task_conditioned
|
|
68
|
+
and "external_task_criteria_file" in arguments
|
|
69
|
+
and self.label_access != "context_only"
|
|
70
|
+
):
|
|
71
|
+
raise ValueError("this command is task-independent; do not pass external_task_criteria_file")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _step_spec(request):
|
|
75
|
+
artifacts = []
|
|
76
|
+
external = []
|
|
77
|
+
requires_splits = False
|
|
78
|
+
label_access = "none"
|
|
79
|
+
task_conditioned = False
|
|
80
|
+
for field, kind in request.argument_fields():
|
|
81
|
+
if field.name in ("output", "execution"):
|
|
82
|
+
continue
|
|
83
|
+
required = field.default is MISSING and field.default_factory is MISSING
|
|
84
|
+
if isinstance(kind, type) and issubclass(kind, ArtifactReference):
|
|
85
|
+
artifacts.append(ArtifactInput(field.metadata["argument"], kind.kind))
|
|
86
|
+
elif kind is PredictionIndex:
|
|
87
|
+
external.append("external_index_dir")
|
|
88
|
+
requires_splits = True
|
|
89
|
+
elif kind is TrainingLabels:
|
|
90
|
+
external.append("external_labels_dir")
|
|
91
|
+
label_access = "train_tuning"
|
|
92
|
+
elif kind is TaskSpec:
|
|
93
|
+
external.append("external_task_criteria_file")
|
|
94
|
+
task_conditioned = True
|
|
95
|
+
elif field.name == "context":
|
|
96
|
+
label_access = "context_only"
|
|
97
|
+
elif required and "argument" in field.metadata:
|
|
98
|
+
external.append(field.metadata["argument"])
|
|
99
|
+
return StepSpec(
|
|
100
|
+
request.result_type.kind,
|
|
101
|
+
request.output_argument,
|
|
102
|
+
tuple(artifacts),
|
|
103
|
+
tuple(external),
|
|
104
|
+
requires_splits,
|
|
105
|
+
label_access,
|
|
106
|
+
task_conditioned,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
ALGORITHM_PROFILES = MappingProxyType(
|
|
111
|
+
{cls.profile: tuple(CommandName(step) for step in cls.steps) for cls in profile_classes()}
|
|
112
|
+
)
|
|
113
|
+
STEP_SPECS = MappingProxyType(
|
|
114
|
+
{
|
|
115
|
+
step: _step_spec(request_type(profile, step))
|
|
116
|
+
for profile, steps in ALGORITHM_PROFILES.items()
|
|
117
|
+
for step in steps
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
ZERO_SHOT_TASK_COMMANDS = MappingProxyType(
|
|
121
|
+
{
|
|
122
|
+
profile: frozenset(step for step in steps if STEP_SPECS[step].task_conditioned)
|
|
123
|
+
for profile, steps in ALGORITHM_PROFILES.items()
|
|
124
|
+
if profile.startswith("zero_shot_")
|
|
125
|
+
}
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class Command(ABC):
|
|
130
|
+
"""Implement run; dispatch validates argument shapes before invoking algorithm code."""
|
|
131
|
+
|
|
132
|
+
name: CommandName
|
|
133
|
+
|
|
134
|
+
@abstractmethod
|
|
135
|
+
def run(self, arguments: Mapping[str, Any]) -> Path:
|
|
136
|
+
"""Validate upstream compatibility, run, and return a published artifact directory."""
|
|
137
|
+
|
|
138
|
+
def __call__(self, arguments: Mapping[str, Any]) -> Path:
|
|
139
|
+
STEP_SPECS[self.name].validate(arguments)
|
|
140
|
+
return self.run(arguments)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class LegacyAlgorithmRepository:
|
|
144
|
+
"""One registry for the union of declared profiles; explicit selection when ambiguous."""
|
|
145
|
+
|
|
146
|
+
def __init__(self, profiles: Sequence[str], commands: Mapping[CommandName, type[Command]]) -> None:
|
|
147
|
+
if isinstance(profiles, str) or not profiles or len(set(profiles)) != len(profiles):
|
|
148
|
+
raise ValueError("profiles must be a nonempty sequence of unique profile names")
|
|
149
|
+
unknown = set(profiles) - ALGORITHM_PROFILES.keys()
|
|
150
|
+
if unknown:
|
|
151
|
+
raise ValueError(f"Unknown algorithm profiles: {sorted(unknown)}")
|
|
152
|
+
expected = {command for profile in profiles for command in ALGORITHM_PROFILES[profile]}
|
|
153
|
+
if set(commands) != expected:
|
|
154
|
+
raise ValueError(f"Declared profiles require {sorted(expected)}; got {sorted(commands)}")
|
|
155
|
+
for name, implementation in commands.items():
|
|
156
|
+
if not isinstance(implementation, type) or not issubclass(implementation, Command):
|
|
157
|
+
raise TypeError(f"{name} must register a Command subclass")
|
|
158
|
+
if implementation.name != name:
|
|
159
|
+
raise ValueError(f"{name} registers a class named {implementation.name}")
|
|
160
|
+
self.profiles = tuple(profiles)
|
|
161
|
+
self.commands = dict(commands)
|
|
162
|
+
|
|
163
|
+
def select_profile(self, profile: str | None = None) -> str:
|
|
164
|
+
if profile is None:
|
|
165
|
+
if len(self.profiles) != 1:
|
|
166
|
+
raise ValueError("Several profiles are declared; select one with --profile")
|
|
167
|
+
return self.profiles[0]
|
|
168
|
+
if profile not in self.profiles:
|
|
169
|
+
raise ValueError(f"Profile {profile!r} is not declared: {self.profiles}")
|
|
170
|
+
return profile
|
|
171
|
+
|
|
172
|
+
def profile_commands(self, profile: str | None = None) -> tuple[CommandName, ...]:
|
|
173
|
+
return ALGORITHM_PROFILES[self.select_profile(profile)]
|
|
174
|
+
|
|
175
|
+
def run(
|
|
176
|
+
self, name: CommandName | str, arguments: Mapping[str, Any], *, profile: str | None = None
|
|
177
|
+
) -> Path:
|
|
178
|
+
selected = self.select_profile(profile)
|
|
179
|
+
command_name = CommandName(name)
|
|
180
|
+
if command_name not in ALGORITHM_PROFILES[selected]:
|
|
181
|
+
raise ValueError(f"{command_name.value} is not part of the {selected} algorithm profile")
|
|
182
|
+
return self.commands[command_name]()(arguments)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CommandName(StrEnum):
|
|
5
|
+
preprocess_data = "preprocess_data"
|
|
6
|
+
pretrain = "pretrain"
|
|
7
|
+
train_supervised = "train_supervised"
|
|
8
|
+
predict_supervised = "predict_supervised"
|
|
9
|
+
train_finetune = "train_finetune"
|
|
10
|
+
predict_finetune = "predict_finetune"
|
|
11
|
+
infer_embeddings = "infer_embeddings"
|
|
12
|
+
train_probe = "train_probe"
|
|
13
|
+
predict_probe = "predict_probe"
|
|
14
|
+
predict_zero_shot_direct = "predict_zero_shot_direct"
|
|
15
|
+
infer_trajectories = "infer_trajectories"
|
|
16
|
+
predict_zero_shot_materialized = "predict_zero_shot_materialized"
|
|
17
|
+
predict_only = "predict_only"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ArtifactType(StrEnum):
|
|
21
|
+
patients = "patients"
|
|
22
|
+
pretrained_algorithm = "pretrained_algorithm"
|
|
23
|
+
embeddings = "embeddings"
|
|
24
|
+
trajectories = "trajectories"
|
|
25
|
+
supervised_algorithm = "supervised_algorithm"
|
|
26
|
+
finetuned_algorithm = "finetuned_algorithm"
|
|
27
|
+
probe_algorithm = "probe_algorithm"
|
|
28
|
+
predictions = "predictions"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Dataset boundaries; index projection is separate from access to label values."""
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Strict split selection and key-only projection. Requires the optional data extra."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .validation import INDEX_COLUMNS, validate_keys, validate_splits
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def split_files(root: Path | str, splits: Sequence[str] | None = None) -> list[Path]:
|
|
12
|
+
"""Select every requested split or fail; never broaden a missing split to all files."""
|
|
13
|
+
root = Path(root)
|
|
14
|
+
if isinstance(splits, str):
|
|
15
|
+
raise TypeError("splits must be a sequence, not a string")
|
|
16
|
+
if splits is not None:
|
|
17
|
+
validate_splits(splits)
|
|
18
|
+
candidates = sorted(root.rglob("*.parquet")) if root.is_dir() else [root]
|
|
19
|
+
candidates = [p for p in candidates if p.is_file() and p.suffix == ".parquet"]
|
|
20
|
+
if not candidates:
|
|
21
|
+
raise FileNotFoundError(f"No parquet index files under {root}")
|
|
22
|
+
split_names = {"train", "tuning", "held_out", *(splits or ())}
|
|
23
|
+
for path in candidates:
|
|
24
|
+
parts = path.relative_to(root).parts[:-1] if root.is_dir() else (root.parent.name,)
|
|
25
|
+
identities = {path.stem, *parts, *([root.name] if root.is_dir() else [])} & split_names
|
|
26
|
+
if len(identities) > 1:
|
|
27
|
+
raise ValueError(f"Ambiguous split identity for {path}: {sorted(identities)}")
|
|
28
|
+
if splits is None:
|
|
29
|
+
return candidates
|
|
30
|
+
selected: set[Path] = set()
|
|
31
|
+
for split in splits:
|
|
32
|
+
matches = []
|
|
33
|
+
for path in candidates:
|
|
34
|
+
# A single file must also prove which split it represents; its unrelated ancestors do not.
|
|
35
|
+
parts = path.relative_to(root).parts[:-1] if root.is_dir() else (root.parent.name,)
|
|
36
|
+
if path.stem == split or split in parts or (root.is_dir() and root.name == split):
|
|
37
|
+
matches.append(path)
|
|
38
|
+
if not matches:
|
|
39
|
+
raise FileNotFoundError(f"No parquet files for requested split {split!r} under {root}")
|
|
40
|
+
selected.update(matches)
|
|
41
|
+
return sorted(selected)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def read_index(root: Path | str, splits: Sequence[str] | None = None):
|
|
45
|
+
"""Project keys at the Parquet read, never loading label columns into the algorithm boundary."""
|
|
46
|
+
import polars as pl
|
|
47
|
+
|
|
48
|
+
frames = []
|
|
49
|
+
for path in split_files(root, splits):
|
|
50
|
+
schema = pl.read_parquet_schema(path)
|
|
51
|
+
if not all(column in schema for column in INDEX_COLUMNS):
|
|
52
|
+
raise ValueError(f"Index file {path} is missing required key columns")
|
|
53
|
+
frames.append(pl.read_parquet(path, columns=list(INDEX_COLUMNS)))
|
|
54
|
+
index = pl.concat(frames)
|
|
55
|
+
validate_keys(index, source="requested index")
|
|
56
|
+
return index
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Explicit training/context targets and strict split-scoped outcome reads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from .index import read_index, split_files
|
|
10
|
+
from .validation import INDEX_COLUMNS, validate_keys, validate_splits
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
import polars as pl
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def read_targets(root: Path | str, splits: Sequence[str], *, allow_unlabelled=False) -> pl.DataFrame | None:
|
|
17
|
+
import polars as pl
|
|
18
|
+
|
|
19
|
+
validate_splits(splits)
|
|
20
|
+
files = split_files(root, splits)
|
|
21
|
+
schemas = [pl.read_parquet_schema(path) for path in files]
|
|
22
|
+
if any(not all(key in schema for key in INDEX_COLUMNS) for schema in schemas):
|
|
23
|
+
raise ValueError("target files are missing required key columns")
|
|
24
|
+
labelled = ["boolean_value" in schema for schema in schemas]
|
|
25
|
+
if not any(labelled) and allow_unlabelled:
|
|
26
|
+
read_index(root, splits)
|
|
27
|
+
return None
|
|
28
|
+
if not all(labelled):
|
|
29
|
+
raise ValueError("every selected target file must contain boolean_value; mixed schemas are invalid")
|
|
30
|
+
frame = pl.concat([pl.read_parquet(path, columns=[*INDEX_COLUMNS, "boolean_value"]) for path in files])
|
|
31
|
+
validate_keys(frame, source="targets")
|
|
32
|
+
if frame["boolean_value"].dtype != pl.Boolean or frame["boolean_value"].null_count():
|
|
33
|
+
raise ValueError("boolean_value must contain non-null Boolean outcomes")
|
|
34
|
+
return frame
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def read_context_labels(
|
|
38
|
+
labels_dir: Path | str,
|
|
39
|
+
context_splits: Sequence[str],
|
|
40
|
+
evaluated_splits: Sequence[str],
|
|
41
|
+
*,
|
|
42
|
+
evaluation_index_dir: Path | str | None = None,
|
|
43
|
+
) -> pl.DataFrame:
|
|
44
|
+
"""Read disjoint labelled context; neither split names nor keys may overlap evaluation."""
|
|
45
|
+
validate_splits(context_splits)
|
|
46
|
+
validate_splits(evaluated_splits)
|
|
47
|
+
if set(context_splits) & set(evaluated_splits):
|
|
48
|
+
raise ValueError("context splits must be disjoint from evaluated splits")
|
|
49
|
+
context = read_targets(labels_dir, context_splits)
|
|
50
|
+
evaluated = read_index(
|
|
51
|
+
labels_dir if evaluation_index_dir is None else evaluation_index_dir, evaluated_splits
|
|
52
|
+
)
|
|
53
|
+
if context.select(INDEX_COLUMNS).join(evaluated, on=list(INDEX_COLUMNS), how="inner").height:
|
|
54
|
+
raise ValueError("context keys must be disjoint from evaluated keys")
|
|
55
|
+
return context
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Compose feature predicates without overriding the dataset's definitions."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
RESOLVED_PREDICATES_FILENAME = "resolved_feature_predicates.yaml"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _UniqueKeysLoader(yaml.SafeLoader):
|
|
12
|
+
def construct_mapping(self, node, deep=False):
|
|
13
|
+
self.flatten_mapping(node)
|
|
14
|
+
mapping = {}
|
|
15
|
+
for key_node, value_node in node.value:
|
|
16
|
+
key = self.construct_object(key_node, deep=deep)
|
|
17
|
+
if not isinstance(key, str) or not key.strip():
|
|
18
|
+
raise ValueError("Predicate YAML keys must be nonempty strings")
|
|
19
|
+
if key in mapping:
|
|
20
|
+
raise ValueError(f"Duplicate YAML key: {key}")
|
|
21
|
+
mapping[key] = self.construct_object(value_node, deep=deep)
|
|
22
|
+
return mapping
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _digest(data: bytes) -> str:
|
|
26
|
+
return "sha256:" + hashlib.sha256(data).hexdigest()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _read(path: Path):
|
|
30
|
+
data = path.read_bytes()
|
|
31
|
+
try:
|
|
32
|
+
document = yaml.load(data, Loader=_UniqueKeysLoader)
|
|
33
|
+
except yaml.YAMLError as error:
|
|
34
|
+
raise ValueError(f"Invalid predicates YAML in {path}: {error}") from error
|
|
35
|
+
if not isinstance(document, dict) or set(document) != {"predicates"}:
|
|
36
|
+
raise ValueError(f"{path} must contain only a predicates mapping, not task/cohort configuration")
|
|
37
|
+
predicates = document["predicates"]
|
|
38
|
+
if not isinstance(predicates, dict) or any(
|
|
39
|
+
not isinstance(definition, dict) or not definition for definition in predicates.values()
|
|
40
|
+
):
|
|
41
|
+
raise ValueError(f"{path} predicates must map names to nonempty definitions")
|
|
42
|
+
return predicates, {"path": str(path.resolve()), "digest": _digest(data)}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def compose_feature_predicates(base: Path | None, additional: Path) -> tuple[bytes, dict]:
|
|
46
|
+
"""Return deterministic YAML and provenance from the same source snapshots.
|
|
47
|
+
|
|
48
|
+
Accept predicate-only documents in MEDS-DEV's shape. Reject duplicate names even when definitions
|
|
49
|
+
agree. Expression semantics and dataset compatibility remain the algorithm's responsibility.
|
|
50
|
+
"""
|
|
51
|
+
predicates, base_source = ({}, None) if base is None else _read(Path(base))
|
|
52
|
+
additions, additional_source = _read(Path(additional))
|
|
53
|
+
if duplicates := predicates.keys() & additions.keys():
|
|
54
|
+
raise ValueError(f"Additional predicates duplicate dataset names: {sorted(duplicates)}")
|
|
55
|
+
content = yaml.safe_dump({"predicates": predicates | additions}, sort_keys=True).encode("utf-8")
|
|
56
|
+
return content, {
|
|
57
|
+
"base": base_source,
|
|
58
|
+
"additional": additional_source,
|
|
59
|
+
"resolved": {"path": RESOLVED_PREDICATES_FILENAME, "digest": _digest(content)},
|
|
60
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Shared key and split validation without importing a dataframe engine at module import."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
|
|
5
|
+
INDEX_COLUMNS = ("subject_id", "prediction_time")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def validate_splits(splits: Sequence[str]) -> None:
|
|
9
|
+
if (
|
|
10
|
+
isinstance(splits, str)
|
|
11
|
+
or not isinstance(splits, Sequence)
|
|
12
|
+
or not splits
|
|
13
|
+
or any(not isinstance(s, str) or not s.strip() for s in splits)
|
|
14
|
+
or len(set(splits)) != len(splits)
|
|
15
|
+
):
|
|
16
|
+
raise ValueError("splits must be a nonempty sequence of unique nonblank names")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def validate_keys(frame, *, source="index") -> None:
|
|
20
|
+
if not all(column in frame.columns for column in INDEX_COLUMNS):
|
|
21
|
+
raise ValueError(f"{source} is missing required key columns")
|
|
22
|
+
if any(frame[column].null_count() for column in INDEX_COLUMNS):
|
|
23
|
+
raise ValueError(f"{source} contains null keys")
|
|
24
|
+
if frame.select(INDEX_COLUMNS).unique().height != frame.height:
|
|
25
|
+
raise ValueError(f"{source} contains duplicate keys")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Keyed prediction validation and post-score truth attachment."""
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Keyed probabilities and post-score ground-truth attachment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from ..data.labels import read_targets
|
|
10
|
+
from ..data.validation import INDEX_COLUMNS, validate_keys
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
import polars as pl
|
|
14
|
+
PREDICTED_PROBABILITY = "predicted_boolean_probability"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def check_decoded(index: pl.DataFrame, predictions: pl.DataFrame) -> pl.DataFrame:
|
|
18
|
+
"""Require unique, non-null matching keys and finite probabilities in [0, 1]."""
|
|
19
|
+
import polars as pl
|
|
20
|
+
|
|
21
|
+
validate_keys(index, source="requested index")
|
|
22
|
+
validate_keys(predictions, source="decoded predictions")
|
|
23
|
+
keys = list(INDEX_COLUMNS)
|
|
24
|
+
missing = index.select(keys).join(predictions.select(keys), on=keys, how="anti")
|
|
25
|
+
extra = predictions.select(keys).join(index.select(keys), on=keys, how="anti")
|
|
26
|
+
if missing.height or extra.height:
|
|
27
|
+
raise ValueError(f"decoded predictions miss {missing.height} and add {extra.height} requested keys")
|
|
28
|
+
if PREDICTED_PROBABILITY not in predictions.columns:
|
|
29
|
+
raise ValueError(f"decoded predictions lack {PREDICTED_PROBABILITY}")
|
|
30
|
+
values = predictions[PREDICTED_PROBABILITY]
|
|
31
|
+
if (
|
|
32
|
+
values.dtype not in (pl.Float32, pl.Float64)
|
|
33
|
+
or values.null_count()
|
|
34
|
+
or not values.is_finite().all()
|
|
35
|
+
or not values.is_between(0.0, 1.0).all()
|
|
36
|
+
):
|
|
37
|
+
raise ValueError(f"{PREDICTED_PROBABILITY} must be a finite non-null probability in [0, 1]")
|
|
38
|
+
return predictions
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def attach_ground_truth(
|
|
42
|
+
predictions: pl.DataFrame, labels_dir: Path | str, splits: Sequence[str]
|
|
43
|
+
) -> pl.DataFrame:
|
|
44
|
+
"""Join truth only after scores are fixed; an entirely unlabelled index is supported.
|
|
45
|
+
|
|
46
|
+
This function cannot enforce when the caller scores. Its result must never feed back into
|
|
47
|
+
preprocessing, algorithm fitting, selection or scoring. Missing splits/mixed label schemas fail.
|
|
48
|
+
"""
|
|
49
|
+
validate_keys(predictions, source="predictions")
|
|
50
|
+
labels = read_targets(labels_dir, splits, allow_unlabelled=True)
|
|
51
|
+
if labels is None:
|
|
52
|
+
return predictions
|
|
53
|
+
joined = predictions.drop("boolean_value", strict=False).join(labels, on=list(INDEX_COLUMNS), how="left")
|
|
54
|
+
if joined.height != predictions.height or joined["boolean_value"].null_count():
|
|
55
|
+
raise ValueError("every prediction key must have exactly one ground-truth label")
|
|
56
|
+
return joined
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Repository registration and external runner adapters."""
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Install this repository's descriptor into a MEDS-DEV checkout."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from ..artifacts.publication import staged_directory
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def register(repo: Path, meds_dev: Path, name: str, profile: str | None = None) -> Path:
|
|
14
|
+
source = repo.resolve()
|
|
15
|
+
descriptors = {p.parent.name: p for p in (source / "algorithms").glob("*/model.yaml")}
|
|
16
|
+
if profile is None:
|
|
17
|
+
if len(descriptors) != 1:
|
|
18
|
+
raise ValueError("Select --profile when the repository has several descriptors")
|
|
19
|
+
profile = next(iter(descriptors))
|
|
20
|
+
if profile not in descriptors:
|
|
21
|
+
raise ValueError(f"No descriptor for {profile!r}; available: {sorted(descriptors)}")
|
|
22
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]*", name):
|
|
23
|
+
raise ValueError("name must be a single alphanumeric, underscore or hyphen identifier")
|
|
24
|
+
target = meds_dev.resolve() / "src" / "MEDS_DEV" / "models" / f"{name}__{profile}"
|
|
25
|
+
if not target.parent.is_dir():
|
|
26
|
+
raise FileNotFoundError(f"{meds_dev} has no src/MEDS_DEV/models directory")
|
|
27
|
+
requirements = (source / "requirements.txt").read_text()
|
|
28
|
+
# A file URL needs no quoting: uv reads quotes in requirement files literally, and pip splits on spaces.
|
|
29
|
+
requirements = (
|
|
30
|
+
"\n".join(
|
|
31
|
+
f"-e {source.as_uri()}" if line.strip() == "-e ." else line for line in requirements.splitlines()
|
|
32
|
+
)
|
|
33
|
+
+ "\n"
|
|
34
|
+
)
|
|
35
|
+
with staged_directory(target) as staging:
|
|
36
|
+
shutil.copy2(descriptors[profile], staging / "model.yaml")
|
|
37
|
+
(staging / "requirements.txt").write_text(requirements)
|
|
38
|
+
predicates = source / "predicates.yaml"
|
|
39
|
+
if predicates.is_file():
|
|
40
|
+
shutil.copy2(predicates, staging / "predicates.yaml")
|
|
41
|
+
return target
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def main() -> None:
|
|
45
|
+
parser = argparse.ArgumentParser()
|
|
46
|
+
parser.add_argument("--meds-dev", type=Path, required=True)
|
|
47
|
+
parser.add_argument("--repo", type=Path, default=Path.cwd())
|
|
48
|
+
parser.add_argument("--name", required=True)
|
|
49
|
+
parser.add_argument("--profile")
|
|
50
|
+
args = parser.parse_args()
|
|
51
|
+
print(register(args.repo, args.meds_dev, args.name, args.profile))
|