medagentrx 0.1.0__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.
- medagentrx/__init__.py +30 -0
- medagentrx/__main__.py +8 -0
- medagentrx/api.py +109 -0
- medagentrx/card.py +152 -0
- medagentrx/checks/__init__.py +14 -0
- medagentrx/checks/context.py +111 -0
- medagentrx/checks/data_checks.py +265 -0
- medagentrx/checks/doc_checks.py +78 -0
- medagentrx/checks/life_checks.py +71 -0
- medagentrx/checks/perf_checks.py +416 -0
- medagentrx/checks/plan_checks.py +147 -0
- medagentrx/checks/registry.py +108 -0
- medagentrx/cli.py +669 -0
- medagentrx/demo/__init__.py +7 -0
- medagentrx/demo/cohort.py +194 -0
- medagentrx/demo/intended-use.txt +1 -0
- medagentrx/demo/plan.py +105 -0
- medagentrx/demo/scenarios.py +639 -0
- medagentrx/document.py +175 -0
- medagentrx/engine.py +487 -0
- medagentrx/errors.py +37 -0
- medagentrx/exit_codes.py +14 -0
- medagentrx/guard.py +296 -0
- medagentrx/history.py +55 -0
- medagentrx/io/__init__.py +3 -0
- medagentrx/io/fingerprint.py +45 -0
- medagentrx/io/hashing.py +19 -0
- medagentrx/io/limits.py +30 -0
- medagentrx/io/manifest.py +123 -0
- medagentrx/io/metadata.py +67 -0
- medagentrx/io/paths.py +88 -0
- medagentrx/io/predictions.py +252 -0
- medagentrx/io/tables.py +213 -0
- medagentrx/io/text.py +26 -0
- medagentrx/ledger.py +601 -0
- medagentrx/plan/__init__.py +19 -0
- medagentrx/plan/conditions.py +256 -0
- medagentrx/plan/fingerprint.py +26 -0
- medagentrx/plan/loader.py +127 -0
- medagentrx/plan/logic.py +71 -0
- medagentrx/plan/models.py +437 -0
- medagentrx/plan/schema.py +32 -0
- medagentrx/plan/template.py +123 -0
- medagentrx/py.typed +0 -0
- medagentrx/render_plan.py +373 -0
- medagentrx/report/__init__.py +3 -0
- medagentrx/report/build.py +151 -0
- medagentrx/report/figures.py +320 -0
- medagentrx/report/html.py +80 -0
- medagentrx/report/json_report.py +24 -0
- medagentrx/report/junit.py +52 -0
- medagentrx/report/markdown.py +44 -0
- medagentrx/report/report.schema.json +86 -0
- medagentrx/report/templates/report.html.j2 +118 -0
- medagentrx/report/writer.py +63 -0
- medagentrx/safe_yaml.py +137 -0
- medagentrx/signing.py +133 -0
- medagentrx/slack.py +183 -0
- medagentrx/stats/__init__.py +7 -0
- medagentrx/stats/_common.py +126 -0
- medagentrx/stats/alpha.py +91 -0
- medagentrx/stats/bootstrap.py +180 -0
- medagentrx/stats/calibration.py +204 -0
- medagentrx/stats/delong.py +230 -0
- medagentrx/stats/metrics.py +50 -0
- medagentrx/stats/multiplicity.py +94 -0
- medagentrx/stats/net_benefit.py +93 -0
- medagentrx/stats/power.py +159 -0
- medagentrx/stats/proportions.py +182 -0
- medagentrx/stats/smd.py +56 -0
- medagentrx/web/__init__.py +3 -0
- medagentrx/web/app.py +202 -0
- medagentrx/web/static/style.css +22 -0
- medagentrx/web/templates/base.html +22 -0
- medagentrx/web/templates/budgets.html +13 -0
- medagentrx/web/templates/entry.html +17 -0
- medagentrx/web/templates/overview.html +39 -0
- medagentrx/web/templates/timeline.html +12 -0
- medagentrx-0.1.0.dist-info/METADATA +201 -0
- medagentrx-0.1.0.dist-info/RECORD +84 -0
- medagentrx-0.1.0.dist-info/WHEEL +4 -0
- medagentrx-0.1.0.dist-info/entry_points.txt +3 -0
- medagentrx-0.1.0.dist-info/licenses/LICENSE +73 -0
- medagentrx-0.1.0.dist-info/licenses/NOTICE +4 -0
medagentrx/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Ahmed Hemedan
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""MedAgentRx: an executable gate for changes to clinical prediction models.
|
|
4
|
+
|
|
5
|
+
Every model update, by an engineer, a pipeline or an AI agent, is checked
|
|
6
|
+
against its approved change plan and receives one verdict: ACCEPTED, FAILED
|
|
7
|
+
or OUTSIDE_PLAN.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
|
|
12
|
+
from medagentrx.api import CheckResult, check
|
|
13
|
+
from medagentrx.engine import Evaluation, Verdict, evaluate
|
|
14
|
+
from medagentrx.errors import InputError, MedAgentRxError, PlanError, StatsError
|
|
15
|
+
from medagentrx.plan import ChangePlan, load_plan
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"ChangePlan",
|
|
19
|
+
"CheckResult",
|
|
20
|
+
"Evaluation",
|
|
21
|
+
"InputError",
|
|
22
|
+
"MedAgentRxError",
|
|
23
|
+
"PlanError",
|
|
24
|
+
"StatsError",
|
|
25
|
+
"Verdict",
|
|
26
|
+
"__version__",
|
|
27
|
+
"check",
|
|
28
|
+
"evaluate",
|
|
29
|
+
"load_plan",
|
|
30
|
+
]
|
medagentrx/__main__.py
ADDED
medagentrx/api.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Ahmed Hemedan
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Python API: check one update and record it, as ``medagentrx check`` does.
|
|
4
|
+
|
|
5
|
+
from medagentrx import check, Verdict
|
|
6
|
+
|
|
7
|
+
result = check(plan="change-plan.yaml", candidate="cand.parquet",
|
|
8
|
+
previous="prev.parquet", anchor="anchor.parquet",
|
|
9
|
+
manifest="train_manifest.parquet", model_meta="model.yaml",
|
|
10
|
+
ledger="ledger.jsonl", out="report/")
|
|
11
|
+
assert result.verdict is Verdict.ACCEPTED
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Iterable
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from medagentrx.engine import Evaluation, Verdict, evaluate
|
|
22
|
+
from medagentrx.errors import PlanError
|
|
23
|
+
from medagentrx.io.limits import Limits
|
|
24
|
+
from medagentrx.ledger import append, exclusive, load_history, read_entries, signing_policy
|
|
25
|
+
from medagentrx.plan import load_plan
|
|
26
|
+
from medagentrx.report.writer import FORMATS, write_reports
|
|
27
|
+
from medagentrx.stats.bootstrap import DEFAULT_REPLICATES
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class CheckResult:
|
|
32
|
+
"""Outcome of ``check``.
|
|
33
|
+
|
|
34
|
+
Attributes:
|
|
35
|
+
evaluation: Every finding and number behind the verdict.
|
|
36
|
+
ledger_entry: The entry written to the ledger, or None for an invalid plan.
|
|
37
|
+
reports: Written report files by format.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
evaluation: Evaluation
|
|
41
|
+
ledger_entry: dict[str, Any] | None
|
|
42
|
+
reports: dict[str, Path] = field(default_factory=dict)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def verdict(self) -> Verdict:
|
|
46
|
+
"""ACCEPTED, FAILED or OUTSIDE_PLAN."""
|
|
47
|
+
return self.evaluation.verdict
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def exit_code(self) -> int:
|
|
51
|
+
"""0 ACCEPTED, 1 FAILED, 2 OUTSIDE_PLAN."""
|
|
52
|
+
return int(self.evaluation.exit_code)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def check(
|
|
56
|
+
*,
|
|
57
|
+
plan: str | Path,
|
|
58
|
+
candidate: str | Path,
|
|
59
|
+
previous: str | Path,
|
|
60
|
+
anchor: str | Path,
|
|
61
|
+
manifest: str | Path,
|
|
62
|
+
model_meta: str | Path,
|
|
63
|
+
ledger: str | Path,
|
|
64
|
+
out: str | Path,
|
|
65
|
+
seed: int = 0,
|
|
66
|
+
n_replicates: int = DEFAULT_REPLICATES,
|
|
67
|
+
formats: Iterable[str] = tuple(FORMATS),
|
|
68
|
+
limits: Limits | None = None,
|
|
69
|
+
signing_key: str | Path | None = None,
|
|
70
|
+
) -> CheckResult:
|
|
71
|
+
"""Check one update against its plan, record it in the ledger and write reports.
|
|
72
|
+
|
|
73
|
+
The ledger is locked from reading its budgets until the new entry is
|
|
74
|
+
written. An invalid plan gives OUTSIDE_PLAN and is not recorded. When the
|
|
75
|
+
plan names a ledger signing key, ``signing_key`` must be that private key;
|
|
76
|
+
the existing head is verified with it and the new head is signed.
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
InputError: If an input file other than the plan is missing or invalid.
|
|
80
|
+
LedgerError: If the ledger fails verification.
|
|
81
|
+
"""
|
|
82
|
+
ledger_path = Path(ledger)
|
|
83
|
+
try:
|
|
84
|
+
change_plan = load_plan(plan)
|
|
85
|
+
except PlanError:
|
|
86
|
+
change_plan = None
|
|
87
|
+
key = signing_policy(change_plan, Path(signing_key) if signing_key is not None else None)
|
|
88
|
+
public_key = key.public_key() if key is not None else None
|
|
89
|
+
ledger_path.parent.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
with exclusive(ledger_path):
|
|
91
|
+
evaluation = evaluate(
|
|
92
|
+
plan=plan,
|
|
93
|
+
candidate=candidate,
|
|
94
|
+
previous=previous,
|
|
95
|
+
anchor=anchor,
|
|
96
|
+
manifest=manifest,
|
|
97
|
+
model_meta=model_meta,
|
|
98
|
+
history=load_history(ledger_path, public_key=public_key),
|
|
99
|
+
seed=seed,
|
|
100
|
+
n_replicates=n_replicates,
|
|
101
|
+
limits=limits,
|
|
102
|
+
)
|
|
103
|
+
entry = None
|
|
104
|
+
if evaluation.plan_id is not None:
|
|
105
|
+
entry = append(ledger_path, evaluation, already_locked=True, signing_key=key)
|
|
106
|
+
reports = write_reports(
|
|
107
|
+
Path(out), evaluation, formats=formats, entry=entry, entries=read_entries(ledger_path)
|
|
108
|
+
)
|
|
109
|
+
return CheckResult(evaluation=evaluation, ledger_entry=entry, reports=reports)
|
medagentrx/card.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Ahmed Hemedan
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Model card sections built from an accepted report.
|
|
4
|
+
|
|
5
|
+
Each version gets one Markdown section between markers. Running the command
|
|
6
|
+
again for the same version replaces that section, so the card never holds
|
|
7
|
+
two copies. Only accepted versions are written, because only they may be
|
|
8
|
+
released.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
from functools import cache
|
|
16
|
+
from importlib import resources
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import jsonschema
|
|
21
|
+
|
|
22
|
+
from medagentrx.errors import InputError
|
|
23
|
+
from medagentrx.io.paths import resolve_input
|
|
24
|
+
from medagentrx.io.text import read_text_limited
|
|
25
|
+
from medagentrx.plan import plan_fingerprint
|
|
26
|
+
from medagentrx.plan.models import ChangePlan
|
|
27
|
+
from medagentrx.report.build import check
|
|
28
|
+
from medagentrx.report.figures import signed
|
|
29
|
+
|
|
30
|
+
MAX_REPORT_BYTES = 50_000_000
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@cache
|
|
34
|
+
def report_schema() -> dict[str, Any]:
|
|
35
|
+
"""The published JSON Schema of ``report.json``."""
|
|
36
|
+
text = (resources.files("medagentrx.report") / "report.schema.json").read_text(encoding="utf-8")
|
|
37
|
+
schema: dict[str, Any] = json.loads(text)
|
|
38
|
+
return schema
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _reject_constant(name: str) -> None:
|
|
42
|
+
raise ValueError(f"non-standard JSON constant {name}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_report(path: str | Path) -> dict[str, Any]:
|
|
46
|
+
"""Read and validate a ``report.json``.
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
InputError: If the file is unsafe, not JSON, or does not match the schema.
|
|
50
|
+
"""
|
|
51
|
+
real = resolve_input(path)
|
|
52
|
+
text = read_text_limited(real, max_bytes=MAX_REPORT_BYTES)
|
|
53
|
+
try:
|
|
54
|
+
data = json.loads(text, parse_constant=_reject_constant)
|
|
55
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
56
|
+
raise InputError(f"{path}: not valid JSON ({exc})") from None
|
|
57
|
+
errors = sorted(jsonschema.Draft202012Validator(report_schema()).iter_errors(data), key=str)
|
|
58
|
+
if errors:
|
|
59
|
+
raise InputError(
|
|
60
|
+
f"{path}: not a MedAgentRx report",
|
|
61
|
+
[
|
|
62
|
+
f"{'.'.join(map(str, e.absolute_path)) or '<root>'}: {e.message}"
|
|
63
|
+
for e in errors[:10]
|
|
64
|
+
],
|
|
65
|
+
)
|
|
66
|
+
result: dict[str, Any] = data
|
|
67
|
+
return result
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _markers(version: str) -> tuple[str, str]:
|
|
71
|
+
return (
|
|
72
|
+
f"<!-- medagentrx:begin version={version} -->",
|
|
73
|
+
f"<!-- medagentrx:end version={version} -->",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def card_section(report: dict[str, Any]) -> str:
|
|
78
|
+
"""Markdown section for the report's candidate version."""
|
|
79
|
+
card = check(report, "DOC-001")["details"].get("card")
|
|
80
|
+
if card is None:
|
|
81
|
+
raise InputError("the report has no model card data (DOC-001 did not run)")
|
|
82
|
+
version = report["candidate_version"]
|
|
83
|
+
begin, end = _markers(version)
|
|
84
|
+
low, high = card["auroc_ci"]
|
|
85
|
+
rows = [
|
|
86
|
+
("AUROC (95% CI)", f"{card['auroc']:.3f} ({low:.3f} to {high:.3f})"),
|
|
87
|
+
("Calibration slope", f"{card['calibration_slope']:.3f}"),
|
|
88
|
+
("Calibration intercept", signed(card["calibration_intercept"], 3)),
|
|
89
|
+
("Brier score", f"{card['brier']:.4f}"),
|
|
90
|
+
(f"Sensitivity at threshold {card['threshold']:g}", f"{card['sensitivity']:.3f}"),
|
|
91
|
+
(f"Specificity at threshold {card['threshold']:g}", f"{card['specificity']:.3f}"),
|
|
92
|
+
("Locked test set", f"{card['n']} rows, {card['n_pos']} positive"),
|
|
93
|
+
]
|
|
94
|
+
for name, test in check(report, "PERF-001")["details"].get("tests", {}).items():
|
|
95
|
+
label = name.replace("vs_", "AUROC change against ")
|
|
96
|
+
rows.append(
|
|
97
|
+
(label, f"{signed(test['delta'], 4)} (lower bound {signed(test['ci_lower'], 4)})")
|
|
98
|
+
)
|
|
99
|
+
ledger = report.get("ledger") or {}
|
|
100
|
+
entry = f" Ledger entry {ledger['seq']} ({ledger['hash'][:16]})." if ledger.get("hash") else ""
|
|
101
|
+
lines = [
|
|
102
|
+
begin,
|
|
103
|
+
f"## Version {version}",
|
|
104
|
+
"",
|
|
105
|
+
(
|
|
106
|
+
f"Accepted under plan {report['plan']['id']} revision {report['plan']['revision']} "
|
|
107
|
+
f"as modification {report['modification_id']}, replacing {report['previous_version']}. "
|
|
108
|
+
f"Evaluated {report['evaluated_at'][:10]}.{entry}"
|
|
109
|
+
),
|
|
110
|
+
"",
|
|
111
|
+
"| Measure | Value |",
|
|
112
|
+
"|---|---|",
|
|
113
|
+
*(f"| {k} | {v} |" for k, v in rows),
|
|
114
|
+
"",
|
|
115
|
+
end,
|
|
116
|
+
]
|
|
117
|
+
return "\n".join(lines) + "\n"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def update_card(card_path: Path, plan: ChangePlan, report: dict[str, Any]) -> Path:
|
|
121
|
+
"""Insert or replace the report's version section in the model card.
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
InputError: If the report is not accepted, belongs to another plan, or
|
|
125
|
+
the card path is unsafe.
|
|
126
|
+
"""
|
|
127
|
+
if report["verdict"] != "ACCEPTED":
|
|
128
|
+
raise InputError(
|
|
129
|
+
f"only accepted versions go on the model card; this report is {report['verdict']}"
|
|
130
|
+
)
|
|
131
|
+
if report["plan"]["fingerprint"] != plan_fingerprint(plan):
|
|
132
|
+
raise InputError("the report was produced under a different plan or plan revision")
|
|
133
|
+
if card_path.is_symlink() or (card_path.exists() and not card_path.is_file()):
|
|
134
|
+
raise InputError(f"{card_path}: the model card must be a regular file")
|
|
135
|
+
section = card_section(report)
|
|
136
|
+
begin, end = _markers(report["candidate_version"])
|
|
137
|
+
if card_path.exists():
|
|
138
|
+
text = card_path.read_text(encoding="utf-8")
|
|
139
|
+
else:
|
|
140
|
+
text = (
|
|
141
|
+
f"# Model card: {plan.plan.device}\n\n"
|
|
142
|
+
"Performance of each released version on the locked test set, "
|
|
143
|
+
"recorded by MedAgentRx.\n\n"
|
|
144
|
+
)
|
|
145
|
+
pattern = re.compile(re.escape(begin) + r".*?" + re.escape(end) + r"\n?", re.S)
|
|
146
|
+
if pattern.search(text):
|
|
147
|
+
text = pattern.sub(lambda _: section, text, count=1)
|
|
148
|
+
else:
|
|
149
|
+
text = text.rstrip("\n") + "\n\n" + section
|
|
150
|
+
card_path.parent.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
card_path.write_text(text, encoding="utf-8")
|
|
152
|
+
return card_path
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Ahmed Hemedan
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Check families. Importing this package registers every check."""
|
|
4
|
+
|
|
5
|
+
from medagentrx.checks import ( # noqa: F401
|
|
6
|
+
data_checks,
|
|
7
|
+
doc_checks,
|
|
8
|
+
life_checks,
|
|
9
|
+
perf_checks,
|
|
10
|
+
plan_checks,
|
|
11
|
+
)
|
|
12
|
+
from medagentrx.checks.registry import REGISTRY, Breaks, Family, Finding, Status, catalogue
|
|
13
|
+
|
|
14
|
+
__all__ = ["REGISTRY", "Breaks", "Family", "Finding", "Status", "catalogue"]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Ahmed Hemedan
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Everything the checks read, gathered once per evaluation."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import datetime as dt
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from functools import cached_property
|
|
10
|
+
|
|
11
|
+
from medagentrx.history import History
|
|
12
|
+
from medagentrx.io.manifest import Manifest
|
|
13
|
+
from medagentrx.io.metadata import ModelMeta
|
|
14
|
+
from medagentrx.io.predictions import Predictions, RowMismatch
|
|
15
|
+
from medagentrx.plan.models import ChangePlan
|
|
16
|
+
from medagentrx.stats.alpha import AlphaState
|
|
17
|
+
from medagentrx.stats.delong import AucCovariance, delong
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Versions:
|
|
22
|
+
"""Candidate, previous and anchor predictions aligned on the same rows."""
|
|
23
|
+
|
|
24
|
+
candidate: Predictions
|
|
25
|
+
previous: Predictions
|
|
26
|
+
anchor: Predictions
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Context:
|
|
31
|
+
"""Inputs and shared results for one evaluation.
|
|
32
|
+
|
|
33
|
+
Attributes:
|
|
34
|
+
plan: The validated change plan.
|
|
35
|
+
plan_fingerprint: Fingerprint of the plan.
|
|
36
|
+
meta: Candidate model metadata.
|
|
37
|
+
candidate: Candidate predictions as loaded.
|
|
38
|
+
test_fingerprint: Fingerprint of the candidate's test rows and labels.
|
|
39
|
+
versions: Aligned predictions, or None if rows or labels differ.
|
|
40
|
+
mismatches: Row mismatch per comparator file, empty when aligned.
|
|
41
|
+
manifest: Training data manifest.
|
|
42
|
+
manifest_sha256: SHA-256 of the manifest file.
|
|
43
|
+
prediction_sha256: SHA-256 of the candidate, previous and anchor files, by role.
|
|
44
|
+
test_features: Feature columns present in both the manifest and the test file.
|
|
45
|
+
history: Budgets used by earlier evaluations.
|
|
46
|
+
alpha_state: Alpha accounting before this evaluation.
|
|
47
|
+
alpha: One-sided level for this evaluation, or None if the budget is exhausted.
|
|
48
|
+
seed: Seed for every resampling step.
|
|
49
|
+
n_replicates: Bootstrap replicates.
|
|
50
|
+
now: Time of the evaluation.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
plan: ChangePlan
|
|
54
|
+
plan_fingerprint: str
|
|
55
|
+
meta: ModelMeta
|
|
56
|
+
candidate: Predictions
|
|
57
|
+
test_fingerprint: str
|
|
58
|
+
versions: Versions | None
|
|
59
|
+
mismatches: dict[str, RowMismatch]
|
|
60
|
+
manifest: Manifest
|
|
61
|
+
manifest_sha256: str
|
|
62
|
+
prediction_sha256: dict[str, str]
|
|
63
|
+
test_features: dict[str, tuple[str, ...]]
|
|
64
|
+
history: History
|
|
65
|
+
alpha_state: AlphaState
|
|
66
|
+
alpha: float | None
|
|
67
|
+
seed: int
|
|
68
|
+
n_replicates: int
|
|
69
|
+
now: dt.datetime
|
|
70
|
+
notes: list[str] = field(default_factory=list)
|
|
71
|
+
outside_plan_failures: list[str] = field(default_factory=list)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def test_set_is_locked(self) -> bool:
|
|
75
|
+
"""True when the candidate's test rows match the plan's fingerprint."""
|
|
76
|
+
return self.test_fingerprint == self.plan.data_management.locked_test_set.sha256
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def performance_blocker(self) -> str | None:
|
|
80
|
+
"""Why performance cannot be assessed, or None when it can.
|
|
81
|
+
|
|
82
|
+
An update already outside the plan is not evaluated on the locked
|
|
83
|
+
test set, so it spends no look.
|
|
84
|
+
"""
|
|
85
|
+
if self.outside_plan_failures:
|
|
86
|
+
return f"the update is outside the plan ({', '.join(self.outside_plan_failures)})"
|
|
87
|
+
if not self.test_set_is_locked:
|
|
88
|
+
return "the test set is not the locked test set (DATA-001)"
|
|
89
|
+
if self.versions is None:
|
|
90
|
+
return "prediction files do not share the same rows and labels (DATA-001)"
|
|
91
|
+
if self.alpha is None:
|
|
92
|
+
return "the alpha budget is exhausted (LIFE-002)"
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
def require_versions(self) -> Versions:
|
|
96
|
+
"""Aligned predictions. Only call after ``performance_blocker`` is None."""
|
|
97
|
+
if self.versions is None: # pragma: no cover # guarded by performance_blocker
|
|
98
|
+
raise RuntimeError("predictions are not aligned")
|
|
99
|
+
return self.versions
|
|
100
|
+
|
|
101
|
+
def require_alpha(self) -> float:
|
|
102
|
+
"""Level for this evaluation. Only call after ``performance_blocker`` is None."""
|
|
103
|
+
if self.alpha is None: # pragma: no cover # guarded by performance_blocker
|
|
104
|
+
raise RuntimeError("alpha budget exhausted")
|
|
105
|
+
return self.alpha
|
|
106
|
+
|
|
107
|
+
@cached_property
|
|
108
|
+
def aucs(self) -> AucCovariance:
|
|
109
|
+
"""DeLong AUROC and covariance of candidate, previous and anchor, in that order."""
|
|
110
|
+
v = self.require_versions()
|
|
111
|
+
return delong(v.candidate.y_true, v.candidate.y_score, v.previous.y_score, v.anchor.y_score)
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Ahmed Hemedan
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""DATA checks: was the update trained and tested on the data the plan allows?"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import math
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from medagentrx.checks.context import Context
|
|
13
|
+
from medagentrx.checks.registry import Breaks, Family, Finding, Status, check
|
|
14
|
+
from medagentrx.io.predictions import ROW_ID
|
|
15
|
+
from medagentrx.plan.conditions import evaluate_columns
|
|
16
|
+
from medagentrx.stats.smd import SMD_WARNING, categorical_smd, numeric_smd
|
|
17
|
+
|
|
18
|
+
EXAMPLES = 5
|
|
19
|
+
MAX_LEVELS = 50
|
|
20
|
+
"""Categorical columns with more levels are identifiers, not features, and are skipped."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@check(
|
|
24
|
+
"DATA-001",
|
|
25
|
+
Family.DATA,
|
|
26
|
+
"Locked test set is unchanged",
|
|
27
|
+
Breaks.OUTSIDE_PLAN,
|
|
28
|
+
"Modification Protocol, data management",
|
|
29
|
+
)
|
|
30
|
+
def locked_test_set(ctx: Context) -> Finding:
|
|
31
|
+
"""Fingerprint must match the plan, and all versions must share rows and labels."""
|
|
32
|
+
planned = ctx.plan.data_management.locked_test_set.sha256
|
|
33
|
+
details: dict[str, object] = {
|
|
34
|
+
"plan_fingerprint": planned,
|
|
35
|
+
"candidate_fingerprint": ctx.test_fingerprint,
|
|
36
|
+
"rows": ctx.candidate.n,
|
|
37
|
+
"mismatches": {name: m.describe() for name, m in ctx.mismatches.items()},
|
|
38
|
+
}
|
|
39
|
+
if not ctx.test_set_is_locked:
|
|
40
|
+
return Finding(
|
|
41
|
+
"DATA-001",
|
|
42
|
+
Status.FAIL,
|
|
43
|
+
"Test set fingerprint does not match the plan; rows or labels have changed",
|
|
44
|
+
details,
|
|
45
|
+
)
|
|
46
|
+
if ctx.mismatches:
|
|
47
|
+
names = ", ".join(sorted(ctx.mismatches))
|
|
48
|
+
return Finding(
|
|
49
|
+
"DATA-001",
|
|
50
|
+
Status.FAIL,
|
|
51
|
+
f"Prediction files for {names} do not have the same rows and labels as the candidate",
|
|
52
|
+
details,
|
|
53
|
+
)
|
|
54
|
+
return Finding(
|
|
55
|
+
"DATA-001", Status.PASS, f"Locked test set matches ({ctx.candidate.n} rows)", details
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _test_keys(ctx: Context, key: str) -> set[str]:
|
|
60
|
+
if key == ROW_ID:
|
|
61
|
+
return set(ctx.candidate.row_id.tolist())
|
|
62
|
+
return set(ctx.candidate.subgroups[key].tolist())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _train_keys(ctx: Context, key: str) -> set[str]:
|
|
66
|
+
manifest = ctx.manifest
|
|
67
|
+
if key == ROW_ID:
|
|
68
|
+
return set(manifest.row_id)
|
|
69
|
+
if key == "patient_id":
|
|
70
|
+
return set(manifest.patient_id)
|
|
71
|
+
return {str(v) for v in manifest.columns[key] if v is not None}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@check(
|
|
75
|
+
"DATA-002",
|
|
76
|
+
Family.DATA,
|
|
77
|
+
"No overlap between training and test data",
|
|
78
|
+
Breaks.FAILED,
|
|
79
|
+
"Data management; test data sequestration",
|
|
80
|
+
)
|
|
81
|
+
def no_overlap(ctx: Context) -> Finding:
|
|
82
|
+
"""No value of a ``forbid_overlap_on`` key may occur in both training and test data."""
|
|
83
|
+
overlaps: dict[str, dict[str, object]] = {}
|
|
84
|
+
for key in ctx.plan.data_management.forbid_overlap_on:
|
|
85
|
+
shared = sorted(_test_keys(ctx, key) & _train_keys(ctx, key))
|
|
86
|
+
if shared:
|
|
87
|
+
overlaps[key] = {"count": len(shared), "examples": shared[:EXAMPLES]}
|
|
88
|
+
details = {"keys": list(ctx.plan.data_management.forbid_overlap_on), "overlaps": overlaps}
|
|
89
|
+
if overlaps:
|
|
90
|
+
summary = "; ".join(f"{k}: {v['count']}" for k, v in overlaps.items())
|
|
91
|
+
return Finding(
|
|
92
|
+
"DATA-002", Status.FAIL, f"Training data overlaps the test set ({summary})", details
|
|
93
|
+
)
|
|
94
|
+
return Finding(
|
|
95
|
+
"DATA-002", Status.PASS, "No training row shares a key with the test set", details
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@check(
|
|
100
|
+
"DATA-003",
|
|
101
|
+
Family.DATA,
|
|
102
|
+
"Every training row meets the inclusion criteria",
|
|
103
|
+
Breaks.OUTSIDE_PLAN,
|
|
104
|
+
"Data management; AI Act Art. 10",
|
|
105
|
+
)
|
|
106
|
+
def training_inclusion(ctx: Context) -> Finding:
|
|
107
|
+
"""Every manifest row must satisfy ``training_inclusion``.
|
|
108
|
+
|
|
109
|
+
The manifest must also be the one named in the model metadata, or the
|
|
110
|
+
training data cannot be verified.
|
|
111
|
+
"""
|
|
112
|
+
manifest = ctx.manifest
|
|
113
|
+
if ctx.manifest_sha256 != ctx.meta.training_manifest_sha256:
|
|
114
|
+
return Finding(
|
|
115
|
+
"DATA-003",
|
|
116
|
+
Status.FAIL,
|
|
117
|
+
"Training manifest does not match training_manifest_sha256 in the model metadata",
|
|
118
|
+
{"manifest_sha256": ctx.manifest_sha256, "metadata": ctx.meta.training_manifest_sha256},
|
|
119
|
+
)
|
|
120
|
+
condition = ctx.plan.data_management.training_inclusion
|
|
121
|
+
meets = evaluate_columns(condition, manifest.columns, manifest.n)
|
|
122
|
+
failing = [manifest.row_id[i] for i in np.flatnonzero(~meets)]
|
|
123
|
+
details = {"rows": manifest.n, "failing": len(failing), "examples": failing[:EXAMPLES]}
|
|
124
|
+
if failing:
|
|
125
|
+
return Finding(
|
|
126
|
+
"DATA-003",
|
|
127
|
+
Status.FAIL,
|
|
128
|
+
f"{len(failing)} of {manifest.n} training rows do not meet the inclusion criteria",
|
|
129
|
+
details,
|
|
130
|
+
)
|
|
131
|
+
return Finding(
|
|
132
|
+
"DATA-003",
|
|
133
|
+
Status.PASS,
|
|
134
|
+
f"All {manifest.n} training rows meet the inclusion criteria",
|
|
135
|
+
details,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@check(
|
|
140
|
+
"DATA-004",
|
|
141
|
+
Family.DATA,
|
|
142
|
+
"Test-set evaluation budget is not exceeded",
|
|
143
|
+
Breaks.OUTSIDE_PLAN,
|
|
144
|
+
"Test data sequestration; adaptive overfitting (Dwork et al. 2015)",
|
|
145
|
+
)
|
|
146
|
+
def evaluation_budget(ctx: Context) -> Finding:
|
|
147
|
+
"""This look must fit inside ``max_evaluations``."""
|
|
148
|
+
limit = ctx.plan.data_management.locked_test_set.max_evaluations
|
|
149
|
+
used = ctx.history.looks_used
|
|
150
|
+
details = {"looks_used_before": used, "max_evaluations": limit, "this_look": used + 1}
|
|
151
|
+
if used >= limit:
|
|
152
|
+
return Finding(
|
|
153
|
+
"DATA-004",
|
|
154
|
+
Status.FAIL,
|
|
155
|
+
f"All {limit} looks at the locked test set have been used",
|
|
156
|
+
details,
|
|
157
|
+
)
|
|
158
|
+
return Finding(
|
|
159
|
+
"DATA-004", Status.PASS, f"Look {used + 1} of {limit} at the locked test set", details
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _numeric(values: Sequence[object]) -> list[float] | None:
|
|
164
|
+
out: list[float] = []
|
|
165
|
+
for value in values:
|
|
166
|
+
if isinstance(value, bool):
|
|
167
|
+
return None
|
|
168
|
+
if isinstance(value, int | float):
|
|
169
|
+
out.append(float(value))
|
|
170
|
+
continue
|
|
171
|
+
try:
|
|
172
|
+
number = float(str(value))
|
|
173
|
+
except ValueError:
|
|
174
|
+
return None
|
|
175
|
+
if not math.isfinite(number):
|
|
176
|
+
return None
|
|
177
|
+
out.append(number)
|
|
178
|
+
return out
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@check(
|
|
182
|
+
"DATA-005",
|
|
183
|
+
Family.DATA,
|
|
184
|
+
"Population shift between training and test data",
|
|
185
|
+
Breaks.WARNING,
|
|
186
|
+
"Evidence-based principle",
|
|
187
|
+
)
|
|
188
|
+
def population_shift(ctx: Context) -> Finding:
|
|
189
|
+
"""Warn when a shared feature has |SMD| above 0.1 between training and test data."""
|
|
190
|
+
if not ctx.test_features:
|
|
191
|
+
return Finding(
|
|
192
|
+
"DATA-005",
|
|
193
|
+
Status.NOT_APPLICABLE,
|
|
194
|
+
"No feature columns shared by training and test files",
|
|
195
|
+
)
|
|
196
|
+
smds: dict[str, float] = {}
|
|
197
|
+
too_many_levels: list[str] = []
|
|
198
|
+
for name, test_values in ctx.test_features.items():
|
|
199
|
+
train = [v for v in ctx.manifest.columns[name] if v is not None and v != ""]
|
|
200
|
+
test = [v for v in test_values if v != ""]
|
|
201
|
+
if len(train) < 2 or len(test) < 2:
|
|
202
|
+
continue
|
|
203
|
+
train_num, test_num = _numeric(train), _numeric(test)
|
|
204
|
+
if train_num is not None and test_num is not None:
|
|
205
|
+
smds[name] = numeric_smd(train_num, test_num)
|
|
206
|
+
else:
|
|
207
|
+
train_text, test_text = [str(v) for v in train], [str(v) for v in test]
|
|
208
|
+
if len(set(train_text) | set(test_text)) > MAX_LEVELS:
|
|
209
|
+
too_many_levels.append(name)
|
|
210
|
+
continue
|
|
211
|
+
smds[name] = categorical_smd(train_text, test_text)
|
|
212
|
+
shifted = {k: v for k, v in smds.items() if abs(v) > SMD_WARNING}
|
|
213
|
+
details = {
|
|
214
|
+
"smd": smds,
|
|
215
|
+
"threshold": SMD_WARNING,
|
|
216
|
+
"shifted": sorted(shifted),
|
|
217
|
+
"skipped_high_cardinality": too_many_levels,
|
|
218
|
+
}
|
|
219
|
+
if shifted:
|
|
220
|
+
listed = ", ".join(f"{k} ({v:+.2f})" for k, v in sorted(shifted.items()))
|
|
221
|
+
return Finding(
|
|
222
|
+
"DATA-005", Status.WARN, f"Population shift above {SMD_WARNING}: {listed}", details
|
|
223
|
+
)
|
|
224
|
+
return Finding(
|
|
225
|
+
"DATA-005", Status.PASS, f"No feature shifts by more than {SMD_WARNING} SMD", details
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@check(
|
|
230
|
+
"DATA-006",
|
|
231
|
+
Family.DATA,
|
|
232
|
+
"Prediction files are the ones the models produced",
|
|
233
|
+
Breaks.OUTSIDE_PLAN,
|
|
234
|
+
"Traceability; Modification Protocol, update procedures",
|
|
235
|
+
)
|
|
236
|
+
def prediction_provenance(ctx: Context) -> Finding:
|
|
237
|
+
"""Bind each prediction file to its model.
|
|
238
|
+
|
|
239
|
+
The candidate file must match ``predictions_sha256`` in its metadata. The
|
|
240
|
+
previous file must match the predictions recorded when that version was
|
|
241
|
+
released, if the ledger holds them. The anchor file must match
|
|
242
|
+
``anchor_predictions_sha256`` in the plan, if the plan records it.
|
|
243
|
+
"""
|
|
244
|
+
actual = ctx.prediction_sha256
|
|
245
|
+
expected: dict[str, str | None] = {
|
|
246
|
+
"candidate": ctx.meta.predictions_sha256,
|
|
247
|
+
"previous": ctx.history.released_predictions.get(ctx.meta.previous_version),
|
|
248
|
+
"anchor": ctx.plan.anchor_predictions_sha256,
|
|
249
|
+
}
|
|
250
|
+
mismatched = [
|
|
251
|
+
role for role, want in expected.items() if want is not None and actual[role] != want
|
|
252
|
+
]
|
|
253
|
+
unchecked = [role for role, want in expected.items() if want is None]
|
|
254
|
+
details = {"expected": expected, "actual": actual, "not_bound": unchecked}
|
|
255
|
+
if mismatched:
|
|
256
|
+
return Finding(
|
|
257
|
+
"DATA-006",
|
|
258
|
+
Status.FAIL,
|
|
259
|
+
"Prediction file(s) differ from the recorded model output: " + ", ".join(mismatched),
|
|
260
|
+
details,
|
|
261
|
+
)
|
|
262
|
+
text = "Prediction files match the recorded model outputs"
|
|
263
|
+
if unchecked:
|
|
264
|
+
text += f" ({', '.join(unchecked)} not yet bound)"
|
|
265
|
+
return Finding("DATA-006", Status.PASS, text, details)
|