calm-benchmark 0.1.3__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.
- calm/__init__.py +41 -0
- calm/adapters/__init__.py +6 -0
- calm/adapters/codebook.py +30 -0
- calm/adapters/nhanes.py +70 -0
- calm/adapters/nhanes_2017_2018.py +118 -0
- calm/adapters/synthetic.py +109 -0
- calm/cli.py +86 -0
- calm/config/__init__.py +6 -0
- calm/config/loading.py +19 -0
- calm/config/models.py +132 -0
- calm/domain/__init__.py +5 -0
- calm/domain/entities.py +211 -0
- calm/domain/errors.py +5 -0
- calm/domain/leakage.py +13 -0
- calm/observability/__init__.py +14 -0
- calm/observability/hashing.py +49 -0
- calm/observability/logging.py +17 -0
- calm/observability/provenance.py +46 -0
- calm/orchestration/__init__.py +6 -0
- calm/orchestration/benchmark.py +364 -0
- calm/orchestration/manifest.py +54 -0
- calm/orchestration/runner.py +59 -0
- calm/pipeline/__init__.py +1 -0
- calm/pipeline/attribution.py +62 -0
- calm/pipeline/calibrate.py +118 -0
- calm/pipeline/cohort.py +49 -0
- calm/pipeline/evaluate.py +315 -0
- calm/pipeline/features.py +52 -0
- calm/pipeline/ingest.py +100 -0
- calm/pipeline/labels.py +346 -0
- calm/pipeline/report.py +211 -0
- calm/pipeline/split.py +76 -0
- calm/pipeline/train.py +151 -0
- calm/pipeline/verification.py +79 -0
- calm/ports/__init__.py +5 -0
- calm/ports/data_source.py +12 -0
- calm_benchmark-0.1.3.dist-info/METADATA +180 -0
- calm_benchmark-0.1.3.dist-info/RECORD +41 -0
- calm_benchmark-0.1.3.dist-info/WHEEL +4 -0
- calm_benchmark-0.1.3.dist-info/entry_points.txt +2 -0
- calm_benchmark-0.1.3.dist-info/licenses/LICENSE +21 -0
calm/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Public API for CALM's reproducible menopausal-status benchmark engine."""
|
|
2
|
+
|
|
3
|
+
from calm.cli import app
|
|
4
|
+
from calm.config.loading import load_config
|
|
5
|
+
from calm.config.models import RunConfig
|
|
6
|
+
from calm.pipeline.attribution import FeatureAttribution, PredictionAttribution, attribute
|
|
7
|
+
from calm.pipeline.calibrate import CalibratedModel, PredictionResult, calibrate, predict
|
|
8
|
+
from calm.pipeline.evaluate import EvaluationResult, Subgroup, evaluate
|
|
9
|
+
from calm.pipeline.features import select_features
|
|
10
|
+
from calm.pipeline.labels import LabelResult, build_labels, get_label_rule
|
|
11
|
+
from calm.pipeline.report import ReportArtifacts, ReportWriter
|
|
12
|
+
from calm.pipeline.split import FrozenSplit, stratified_split
|
|
13
|
+
from calm.pipeline.train import TrainedModel, train
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.3"
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"CalibratedModel",
|
|
19
|
+
"FeatureAttribution",
|
|
20
|
+
"EvaluationResult",
|
|
21
|
+
"FrozenSplit",
|
|
22
|
+
"LabelResult",
|
|
23
|
+
"PredictionResult",
|
|
24
|
+
"PredictionAttribution",
|
|
25
|
+
"ReportArtifacts",
|
|
26
|
+
"ReportWriter",
|
|
27
|
+
"RunConfig",
|
|
28
|
+
"Subgroup",
|
|
29
|
+
"TrainedModel",
|
|
30
|
+
"app",
|
|
31
|
+
"attribute",
|
|
32
|
+
"build_labels",
|
|
33
|
+
"calibrate",
|
|
34
|
+
"evaluate",
|
|
35
|
+
"get_label_rule",
|
|
36
|
+
"load_config",
|
|
37
|
+
"predict",
|
|
38
|
+
"select_features",
|
|
39
|
+
"stratified_split",
|
|
40
|
+
"train",
|
|
41
|
+
]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Official NCHS HTML codebook metadata ingestion."""
|
|
2
|
+
|
|
3
|
+
from bs4 import BeautifulSoup
|
|
4
|
+
|
|
5
|
+
from calm.domain.entities import ValueLabel, VariableCode, VariableMetadata
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def parse_codebook(html: str) -> tuple[VariableMetadata, ...]:
|
|
9
|
+
"""Extract variable labels and coded response labels from an NCHS codebook page."""
|
|
10
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
11
|
+
variables: list[VariableMetadata] = []
|
|
12
|
+
for heading in soup.find_all("h3"):
|
|
13
|
+
text = heading.get_text(" ", strip=True)
|
|
14
|
+
code, separator, label = text.partition(" - ")
|
|
15
|
+
if not separator or not code.isupper():
|
|
16
|
+
continue
|
|
17
|
+
table = heading.find_next("table")
|
|
18
|
+
value_labels: list[ValueLabel] = []
|
|
19
|
+
if table is not None:
|
|
20
|
+
for row in table.find_all("tr")[1:]:
|
|
21
|
+
cells = row.find_all("td")
|
|
22
|
+
if len(cells) >= 2:
|
|
23
|
+
value_labels.append(
|
|
24
|
+
ValueLabel(
|
|
25
|
+
cells[0].get_text(" ", strip=True),
|
|
26
|
+
cells[1].get_text(" ", strip=True),
|
|
27
|
+
)
|
|
28
|
+
)
|
|
29
|
+
variables.append(VariableMetadata(VariableCode(code), label, tuple(value_labels)))
|
|
30
|
+
return tuple(variables)
|
calm/adapters/nhanes.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""NHANES raw-file acquisition adapter."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Protocol, cast
|
|
7
|
+
from urllib.request import urlopen
|
|
8
|
+
|
|
9
|
+
from calm.domain.entities import CycleSpec, RawBundle, RawFile
|
|
10
|
+
from calm.observability.hashing import hash_bytes
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ByteFetcher(Protocol):
|
|
14
|
+
"""Fetch the bytes at a configured source URL."""
|
|
15
|
+
|
|
16
|
+
def __call__(self, url: str) -> bytes:
|
|
17
|
+
"""Return source bytes for a URL."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def fetch_url_bytes(url: str) -> bytes:
|
|
21
|
+
"""Fetch raw bytes from a configured URL.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
url: Direct source URL explicitly supplied by orchestration.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Response bytes.
|
|
28
|
+
"""
|
|
29
|
+
with urlopen(url) as response: # noqa: S310
|
|
30
|
+
return cast(bytes, response.read())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class NhanesSource:
|
|
34
|
+
"""Acquire configured NHANES XPT components and record their lineage."""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
destination: Path,
|
|
39
|
+
components: tuple[str, ...],
|
|
40
|
+
url_for: Callable[[CycleSpec, str], str],
|
|
41
|
+
clock: Callable[[], datetime],
|
|
42
|
+
fetcher: ByteFetcher = fetch_url_bytes,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Initialize acquisition dependencies supplied by orchestration."""
|
|
45
|
+
self._destination = destination
|
|
46
|
+
self._components = components
|
|
47
|
+
self._url_for = url_for
|
|
48
|
+
self._clock = clock
|
|
49
|
+
self._fetcher = fetcher
|
|
50
|
+
|
|
51
|
+
def fetch(self, cycle: CycleSpec) -> RawBundle:
|
|
52
|
+
"""Fetch configured components for a cycle and persist their exact bytes."""
|
|
53
|
+
files: list[RawFile] = []
|
|
54
|
+
cycle_directory = self._destination / cycle.identifier
|
|
55
|
+
cycle_directory.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
for component in self._components:
|
|
57
|
+
source_url = self._url_for(cycle, component)
|
|
58
|
+
payload = self._fetcher(source_url)
|
|
59
|
+
local_path = cycle_directory / f"{component}.xpt"
|
|
60
|
+
local_path.write_bytes(payload)
|
|
61
|
+
files.append(
|
|
62
|
+
RawFile(
|
|
63
|
+
component=component,
|
|
64
|
+
source_url=source_url,
|
|
65
|
+
checksum=str(hash_bytes(payload)),
|
|
66
|
+
local_path=local_path,
|
|
67
|
+
fetched_at=self._clock(),
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
return RawBundle(cycle=cycle, files=tuple(files))
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Pinned local adapter and metadata contract for public NHANES 2017--2018."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from calm.domain.entities import (
|
|
8
|
+
CycleSpec,
|
|
9
|
+
RawBundle,
|
|
10
|
+
RawFile,
|
|
11
|
+
ValueLabel,
|
|
12
|
+
VariableCode,
|
|
13
|
+
VariableMetadata,
|
|
14
|
+
)
|
|
15
|
+
from calm.domain.errors import DomainVerificationError
|
|
16
|
+
from calm.observability.hashing import hash_bytes
|
|
17
|
+
|
|
18
|
+
_BASE_URL = "https://wwwn.cdc.gov/Nchs/Data/Nhanes/Public/2017/DataFiles"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Nhanes2017_2018Source:
|
|
22
|
+
"""Read the pinned public 2017--2018 files already present on local storage."""
|
|
23
|
+
|
|
24
|
+
_files = {
|
|
25
|
+
"demographics": (
|
|
26
|
+
"DEMO_J.XPT",
|
|
27
|
+
"c0b46e0345ea19404928656277c8b0d10b0cca348a9b2fe4fc3c67e8b7ee73ec",
|
|
28
|
+
), # noqa: E501
|
|
29
|
+
"questionnaire": (
|
|
30
|
+
"RHQ_J.XPT",
|
|
31
|
+
"c919896216154ea207470dd84e428f5fdd65ced39735148c78fb6032e0b77a7b",
|
|
32
|
+
), # noqa: E501
|
|
33
|
+
"body_measures": (
|
|
34
|
+
"BMX_J.XPT",
|
|
35
|
+
"8d675e42d8826ac98714b2c3dd4c5138a5e353fb4424f7eff5e6db4a01ce838a",
|
|
36
|
+
), # noqa: E501
|
|
37
|
+
"blood_pressure": (
|
|
38
|
+
"BPX_J.XPT",
|
|
39
|
+
"f98f1d9a4c18173e715c749b4294f6f1525140d5ad5751316bb17cc038fb3423",
|
|
40
|
+
), # noqa: E501
|
|
41
|
+
"total_cholesterol": (
|
|
42
|
+
"TCHOL_J.XPT",
|
|
43
|
+
"0291a6a4f6d82dac8392c8c992b519946824dfeda01647935520cf506ed9f4ab",
|
|
44
|
+
), # noqa: E501
|
|
45
|
+
"hdl": ("HDL_J.XPT", "9f4eb41b89f0c9f3d6262f873e671eeb2f2eb40581a8426b2a3c8fd4fc19439e"), # noqa: E501
|
|
46
|
+
"glycated_hemoglobin": (
|
|
47
|
+
"GHB_J.XPT",
|
|
48
|
+
"35f07094573a0061a03ed609a5a363b34eb1b1c7065d1623b43d72e132a8a654",
|
|
49
|
+
), # noqa: E501
|
|
50
|
+
"complete_blood_count": (
|
|
51
|
+
"CBC_J.XPT",
|
|
52
|
+
"00f964098dc91272e415344554cbf1b627ecaeb2eda201518527446a7d81e742",
|
|
53
|
+
), # noqa: E501
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
def __init__(self, directory: Path) -> None:
|
|
57
|
+
"""Initialize the directory containing the downloaded official XPT files."""
|
|
58
|
+
self._directory = directory
|
|
59
|
+
|
|
60
|
+
def fetch(self, cycle: CycleSpec) -> RawBundle:
|
|
61
|
+
"""Validate and expose the immutable local files for the supported cycle."""
|
|
62
|
+
if cycle.identifier != "2017-2018":
|
|
63
|
+
raise DomainVerificationError(f"unsupported NHANES cycle: {cycle.identifier}")
|
|
64
|
+
files: list[RawFile] = []
|
|
65
|
+
for component, (filename, expected_checksum) in self._files.items():
|
|
66
|
+
path = self._directory / filename
|
|
67
|
+
if not path.is_file():
|
|
68
|
+
raise DomainVerificationError(f"missing pinned NHANES input: {path}")
|
|
69
|
+
checksum = str(hash_bytes(path.read_bytes()))
|
|
70
|
+
if checksum != expected_checksum:
|
|
71
|
+
raise DomainVerificationError(
|
|
72
|
+
f"checksum mismatch for pinned NHANES input: {filename}"
|
|
73
|
+
)
|
|
74
|
+
files.append(
|
|
75
|
+
RawFile(
|
|
76
|
+
component=component,
|
|
77
|
+
source_url=f"{_BASE_URL}/{filename}",
|
|
78
|
+
checksum=checksum,
|
|
79
|
+
local_path=path,
|
|
80
|
+
fetched_at=datetime.fromtimestamp(path.stat().st_mtime, UTC),
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
return RawBundle(cycle, tuple(files))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def questionnaire_metadata_2017_2018() -> tuple[VariableMetadata, ...]:
|
|
87
|
+
"""Return the versioned RHQ codebook contract needed by the public label rule."""
|
|
88
|
+
return (
|
|
89
|
+
VariableMetadata(VariableCode("SEQN"), "Respondent sequence number"),
|
|
90
|
+
VariableMetadata(
|
|
91
|
+
VariableCode("RHQ031"),
|
|
92
|
+
"Had regular periods in past 12 months",
|
|
93
|
+
(ValueLabel("1", "Yes"), ValueLabel("2", "No")),
|
|
94
|
+
),
|
|
95
|
+
VariableMetadata(
|
|
96
|
+
VariableCode("RHD043"),
|
|
97
|
+
"Reason not having regular periods",
|
|
98
|
+
(
|
|
99
|
+
ValueLabel("1", "Pregnancy"),
|
|
100
|
+
ValueLabel("2", "Breast feeding"),
|
|
101
|
+
ValueLabel("3", "Hysterectomy"),
|
|
102
|
+
ValueLabel("7", "Menopause/Change of life"),
|
|
103
|
+
),
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def validate_2017_2018_schema(tables: Mapping[str, tuple[str, ...]]) -> None:
|
|
109
|
+
"""Fail before cohort construction unless required source fields are exactly available."""
|
|
110
|
+
required = {
|
|
111
|
+
"demographics": {"SEQN", "RIAGENDR", "RIDAGEYR"},
|
|
112
|
+
"questionnaire": {"SEQN", "RHQ031", "RHD043"},
|
|
113
|
+
}
|
|
114
|
+
for table, columns in required.items():
|
|
115
|
+
available = set(tables.get(table, ()))
|
|
116
|
+
missing = sorted(columns - available)
|
|
117
|
+
if missing:
|
|
118
|
+
raise DomainVerificationError(f"2017-2018 {table} schema mismatch: missing {missing}")
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Deterministic offline NHANES-like source for tests and demonstrations."""
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pandas as pd
|
|
7
|
+
import pyreadstat
|
|
8
|
+
|
|
9
|
+
from calm.domain.entities import CycleSpec, RawBundle, RawFile
|
|
10
|
+
from calm.observability.hashing import hash_bytes
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SyntheticSource:
|
|
14
|
+
"""Produce a metadata-labelled benchmark bundle without network access."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, destination: Path) -> None:
|
|
17
|
+
"""Set the destination for generated fixture data."""
|
|
18
|
+
self._destination = destination
|
|
19
|
+
|
|
20
|
+
def fetch(self, cycle: CycleSpec) -> RawBundle:
|
|
21
|
+
"""Write the component bundle needed by the complete synthetic pipeline."""
|
|
22
|
+
directory = self._destination / cycle.identifier
|
|
23
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
identifiers = list(range(1, 81))
|
|
25
|
+
postmenopausal = [identifier % 2 == 0 for identifier in identifiers]
|
|
26
|
+
paths = {
|
|
27
|
+
"questionnaire": directory / "questionnaire.sav",
|
|
28
|
+
"demographics": directory / "demographics.sav",
|
|
29
|
+
"laboratory": directory / "laboratory.sav",
|
|
30
|
+
}
|
|
31
|
+
pyreadstat.write_sav(
|
|
32
|
+
pd.DataFrame(
|
|
33
|
+
{
|
|
34
|
+
"SEQN": identifiers,
|
|
35
|
+
"SEX": [2] * 80,
|
|
36
|
+
"AGE": [40 + index % 20 for index in range(80)],
|
|
37
|
+
}
|
|
38
|
+
),
|
|
39
|
+
paths["demographics"],
|
|
40
|
+
column_labels={"SEQN": "Respondent sequence number", "SEX": "Sex", "AGE": "Age"},
|
|
41
|
+
variable_value_labels={"SEX": {1: "Male", 2: "Female"}},
|
|
42
|
+
)
|
|
43
|
+
pyreadstat.write_sav(
|
|
44
|
+
pd.DataFrame(
|
|
45
|
+
{
|
|
46
|
+
"SEQN": identifiers,
|
|
47
|
+
"STATUS": [2 if value else 1 for value in postmenopausal],
|
|
48
|
+
"RHQ031": [2 if value else 1 for value in postmenopausal],
|
|
49
|
+
"RHD043": [7 if value else None for value in postmenopausal],
|
|
50
|
+
"RHD280": [2] * 80,
|
|
51
|
+
"RHQ305": [2] * 80,
|
|
52
|
+
"RHD143": [2] * 80,
|
|
53
|
+
"RHQ200": [2] * 80,
|
|
54
|
+
"RHQ540": [2] * 80,
|
|
55
|
+
}
|
|
56
|
+
),
|
|
57
|
+
paths["questionnaire"],
|
|
58
|
+
column_labels={
|
|
59
|
+
column: column
|
|
60
|
+
for column in (
|
|
61
|
+
"SEQN",
|
|
62
|
+
"STATUS",
|
|
63
|
+
"RHQ031",
|
|
64
|
+
"RHD043",
|
|
65
|
+
"RHD280",
|
|
66
|
+
"RHQ305",
|
|
67
|
+
"RHD143",
|
|
68
|
+
"RHQ200",
|
|
69
|
+
"RHQ540",
|
|
70
|
+
)
|
|
71
|
+
},
|
|
72
|
+
variable_value_labels={
|
|
73
|
+
"STATUS": {1: "Yes", 2: "No"},
|
|
74
|
+
"RHQ031": {1: "Yes", 2: "No"},
|
|
75
|
+
"RHD043": {3: "Hysterectomy", 7: "Menopause/Change of life"},
|
|
76
|
+
**{
|
|
77
|
+
column: {1: "Yes", 2: "No"}
|
|
78
|
+
for column in ("RHD280", "RHQ305", "RHD143", "RHQ200", "RHQ540")
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
)
|
|
82
|
+
pyreadstat.write_sav(
|
|
83
|
+
pd.DataFrame(
|
|
84
|
+
{
|
|
85
|
+
"SEQN": identifiers,
|
|
86
|
+
"marker_one": [float(index % 2) for index in range(80)],
|
|
87
|
+
"marker_two": [float((index * 7) % 11) for index in range(80)],
|
|
88
|
+
}
|
|
89
|
+
),
|
|
90
|
+
paths["laboratory"],
|
|
91
|
+
column_labels={
|
|
92
|
+
"SEQN": "Respondent sequence number",
|
|
93
|
+
"marker_one": "Synthetic marker one",
|
|
94
|
+
"marker_two": "Synthetic marker two",
|
|
95
|
+
},
|
|
96
|
+
)
|
|
97
|
+
return RawBundle(
|
|
98
|
+
cycle=cycle,
|
|
99
|
+
files=tuple(
|
|
100
|
+
RawFile(
|
|
101
|
+
component=component,
|
|
102
|
+
source_url=f"synthetic://{component}",
|
|
103
|
+
checksum=str(hash_bytes(path.read_bytes())),
|
|
104
|
+
local_path=path,
|
|
105
|
+
fetched_at=datetime(2026, 1, 1, tzinfo=UTC),
|
|
106
|
+
)
|
|
107
|
+
for component, path in paths.items()
|
|
108
|
+
),
|
|
109
|
+
)
|
calm/cli.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Command-line interface for CALM."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from calm.adapters.nhanes_2017_2018 import Nhanes2017_2018Source
|
|
9
|
+
from calm.adapters.synthetic import SyntheticSource
|
|
10
|
+
from calm.config.loading import load_config
|
|
11
|
+
from calm.config.models import (
|
|
12
|
+
CalibrationConfig,
|
|
13
|
+
CohortConfig,
|
|
14
|
+
CycleConfig,
|
|
15
|
+
FeatureConfig,
|
|
16
|
+
LabelConfig,
|
|
17
|
+
ModelConfig,
|
|
18
|
+
PathsConfig,
|
|
19
|
+
RunConfig,
|
|
20
|
+
SplitConfig,
|
|
21
|
+
)
|
|
22
|
+
from calm.orchestration.benchmark import run_benchmark
|
|
23
|
+
|
|
24
|
+
app = typer.Typer(
|
|
25
|
+
name="calm",
|
|
26
|
+
help="Build reproducible menopausal-status prediction benchmarks.",
|
|
27
|
+
no_args_is_help=True,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.callback()
|
|
32
|
+
def main() -> None:
|
|
33
|
+
"""Run the CALM command-line application."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.command()
|
|
37
|
+
def demo(
|
|
38
|
+
output: Annotated[
|
|
39
|
+
Path, typer.Option(help="Directory in which to write demo artifacts.")
|
|
40
|
+
] = Path("runs"),
|
|
41
|
+
run_id: Annotated[str, typer.Option(help="Stable name for the demo run.")] = "synthetic-demo",
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Run the complete offline synthetic pipeline and write its audit artifacts."""
|
|
44
|
+
config = RunConfig(
|
|
45
|
+
seed=31,
|
|
46
|
+
cycles=(CycleConfig(identifier="2017-2018"),),
|
|
47
|
+
cohort=CohortConfig(age_policy="restricted", minimum_age=40, maximum_age=60),
|
|
48
|
+
label=LabelConfig(
|
|
49
|
+
rule_name="public_cessation",
|
|
50
|
+
perimenopause_policy="exclude",
|
|
51
|
+
include_early_natural_menopause=False,
|
|
52
|
+
),
|
|
53
|
+
features=FeatureConfig(allowlist=("marker_one", "marker_two")),
|
|
54
|
+
split=SplitConfig(validation_fraction=0.2, test_fraction=0.2),
|
|
55
|
+
model=ModelConfig(estimator="logistic_regression", options={"max_iter": 200}),
|
|
56
|
+
calibration=CalibrationConfig(method="sigmoid", no_call_lower=0.45, no_call_upper=0.55),
|
|
57
|
+
paths=PathsConfig(raw_data=output / ".raw", cache=output / ".cache", runs=output),
|
|
58
|
+
)
|
|
59
|
+
result = run_benchmark(config, SyntheticSource(config.paths.raw_data), run_id)
|
|
60
|
+
typer.echo(f"Wrote benchmark artifacts to {result.artifacts.metrics_path.parent}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@app.command("run-synthetic")
|
|
64
|
+
def run_synthetic(
|
|
65
|
+
config_path: Annotated[Path, typer.Option("--config", exists=True, readable=True)],
|
|
66
|
+
run_id: Annotated[
|
|
67
|
+
str, typer.Option(help="Stable name for the benchmark run.")
|
|
68
|
+
] = "synthetic-run",
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Execute the complete synthetic benchmark from a validated TOML configuration."""
|
|
71
|
+
config = load_config(config_path)
|
|
72
|
+
result = run_benchmark(config, SyntheticSource(config.paths.raw_data), run_id)
|
|
73
|
+
typer.echo(f"Wrote benchmark artifacts to {result.artifacts.metrics_path.parent}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@app.command("run-nhanes")
|
|
77
|
+
def run_nhanes(
|
|
78
|
+
config_path: Annotated[Path, typer.Option("--config", exists=True, readable=True)],
|
|
79
|
+
run_id: Annotated[str, typer.Option(help="Stable name for the benchmark run.")] = (
|
|
80
|
+
"nhanes-2017-2018"
|
|
81
|
+
),
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Run the pinned public NHANES 2017--2018 benchmark from downloaded inputs."""
|
|
84
|
+
config = load_config(config_path)
|
|
85
|
+
result = run_benchmark(config, Nhanes2017_2018Source(config.paths.raw_data), run_id)
|
|
86
|
+
typer.echo(f"Wrote benchmark artifacts to {result.artifacts.metrics_path.parent}")
|
calm/config/__init__.py
ADDED
calm/config/loading.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""TOML configuration loading at the application boundary."""
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from calm.config.models import RunConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def load_config(path: Path) -> RunConfig:
|
|
10
|
+
"""Load and validate a run configuration from a TOML file.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
path: Location of the user-supplied TOML configuration.
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
A validated immutable run configuration.
|
|
17
|
+
"""
|
|
18
|
+
with path.open("rb") as config_file:
|
|
19
|
+
return RunConfig.model_validate(tomllib.load(config_file))
|
calm/config/models.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Validated, immutable configuration contracts for CALM runs."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Literal, Self
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ConfigModel(BaseModel):
|
|
10
|
+
"""Base class for immutable, strict CALM configuration models."""
|
|
11
|
+
|
|
12
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CycleConfig(ConfigModel):
|
|
16
|
+
"""A continuous NHANES two-year cycle selected for a run."""
|
|
17
|
+
|
|
18
|
+
identifier: str = Field(pattern=r"^\d{4}-\d{4}$")
|
|
19
|
+
components: tuple[
|
|
20
|
+
Literal["demographics", "examination", "laboratory", "questionnaire"], ...
|
|
21
|
+
] = (
|
|
22
|
+
"demographics",
|
|
23
|
+
"examination",
|
|
24
|
+
"laboratory",
|
|
25
|
+
"questionnaire",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CohortConfig(ConfigModel):
|
|
30
|
+
"""Rules for selecting the analytical cohort."""
|
|
31
|
+
|
|
32
|
+
age_policy: Literal["restricted", "full_range"]
|
|
33
|
+
minimum_age: int = Field(ge=0, le=120)
|
|
34
|
+
maximum_age: int = Field(ge=0, le=120)
|
|
35
|
+
|
|
36
|
+
@model_validator(mode="after")
|
|
37
|
+
def validate_age_window(self) -> Self:
|
|
38
|
+
"""Require a nonempty configured age interval."""
|
|
39
|
+
if self.minimum_age >= self.maximum_age:
|
|
40
|
+
msg = "minimum_age must be less than maximum_age"
|
|
41
|
+
raise ValueError(msg)
|
|
42
|
+
return self
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class LabelConfig(ConfigModel):
|
|
46
|
+
"""Policies governing metadata-verified outcome construction."""
|
|
47
|
+
|
|
48
|
+
rule_name: str = "natural_menopause"
|
|
49
|
+
rule_version: str = "1"
|
|
50
|
+
perimenopause_policy: Literal["exclude", "premenopausal"]
|
|
51
|
+
include_early_natural_menopause: bool = True
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class FeatureConfig(ConfigModel):
|
|
55
|
+
"""An explicit allowlist of candidate non-label feature names."""
|
|
56
|
+
|
|
57
|
+
allowlist: tuple[str, ...] = Field(min_length=1)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SplitConfig(ConfigModel):
|
|
61
|
+
"""Deterministic partition fractions for model development."""
|
|
62
|
+
|
|
63
|
+
test_fraction: float = Field(gt=0, lt=1)
|
|
64
|
+
validation_fraction: float = Field(gt=0, lt=1)
|
|
65
|
+
|
|
66
|
+
@model_validator(mode="after")
|
|
67
|
+
def validate_training_fraction(self) -> Self:
|
|
68
|
+
"""Retain a nonempty training partition."""
|
|
69
|
+
if self.test_fraction + self.validation_fraction >= 1:
|
|
70
|
+
msg = "test_fraction and validation_fraction must sum to less than 1"
|
|
71
|
+
raise ValueError(msg)
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ModelConfig(ConfigModel):
|
|
76
|
+
"""Configured estimator selection and stable scalar options."""
|
|
77
|
+
|
|
78
|
+
estimator: Literal["logistic_regression", "gradient_boosting"]
|
|
79
|
+
options: dict[str, str | int | float | bool] = Field(default_factory=dict)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class CalibrationConfig(ConfigModel):
|
|
83
|
+
"""Probability calibration and abstention-band settings."""
|
|
84
|
+
|
|
85
|
+
method: Literal["sigmoid", "isotonic"]
|
|
86
|
+
no_call_lower: float = Field(ge=0, le=1)
|
|
87
|
+
no_call_upper: float = Field(ge=0, le=1)
|
|
88
|
+
minimum_validation_samples: int = Field(default=10, ge=2)
|
|
89
|
+
minimum_validation_per_class: int = Field(default=5, ge=2)
|
|
90
|
+
selection_protocol: Literal["preselected"] = "preselected"
|
|
91
|
+
|
|
92
|
+
@model_validator(mode="after")
|
|
93
|
+
def validate_no_call_band(self) -> Self:
|
|
94
|
+
"""Require a properly ordered abstention interval."""
|
|
95
|
+
if self.no_call_lower >= self.no_call_upper:
|
|
96
|
+
msg = "no_call_lower must be less than no_call_upper"
|
|
97
|
+
raise ValueError(msg)
|
|
98
|
+
return self
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class EvaluationConfig(ConfigModel):
|
|
102
|
+
"""Uncertainty and reporting policies fixed before frozen-test evaluation."""
|
|
103
|
+
|
|
104
|
+
bootstrap_resamples: int = Field(default=200, ge=100)
|
|
105
|
+
confidence_level: float = Field(default=0.95, gt=0, lt=1)
|
|
106
|
+
survey_weight_decision: Literal["not_applied_individual_level"] = "not_applied_individual_level"
|
|
107
|
+
generalization_protocol: Literal["within_cycle_frozen_test", "held_out_cycle_required"] = (
|
|
108
|
+
"within_cycle_frozen_test"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class PathsConfig(ConfigModel):
|
|
113
|
+
"""Filesystem locations supplied by the application boundary."""
|
|
114
|
+
|
|
115
|
+
raw_data: Path
|
|
116
|
+
cache: Path
|
|
117
|
+
runs: Path
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class RunConfig(ConfigModel):
|
|
121
|
+
"""The complete validated and immutable intent of one CALM run."""
|
|
122
|
+
|
|
123
|
+
seed: int = Field(ge=0)
|
|
124
|
+
cycles: tuple[CycleConfig, ...] = Field(min_length=1)
|
|
125
|
+
cohort: CohortConfig
|
|
126
|
+
label: LabelConfig
|
|
127
|
+
features: FeatureConfig
|
|
128
|
+
split: SplitConfig
|
|
129
|
+
model: ModelConfig
|
|
130
|
+
calibration: CalibrationConfig
|
|
131
|
+
evaluation: EvaluationConfig = EvaluationConfig()
|
|
132
|
+
paths: PathsConfig
|