oi-workforce-dml 0.2.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.
- dml_code/__init__.py +5 -0
- dml_code/cli.py +17 -0
- dml_code/estimation.py +128 -0
- dml_code/src/__init__.py +1 -0
- dml_code/src/config.py +337 -0
- dml_code/src/estimate_helpers.py +292 -0
- dml_code/src/nuisance.py +345 -0
- dml_code/src/paths.py +45 -0
- dml_code/src/plotting.py +1438 -0
- dml_code/src/preprocessing.py +115 -0
- dml_code/src/results.py +205 -0
- dml_code/src/utils.py +184 -0
- oi_workforce_dml-0.2.0.dist-info/METADATA +298 -0
- oi_workforce_dml-0.2.0.dist-info/RECORD +17 -0
- oi_workforce_dml-0.2.0.dist-info/WHEEL +5 -0
- oi_workforce_dml-0.2.0.dist-info/entry_points.txt +2 -0
- oi_workforce_dml-0.2.0.dist-info/top_level.txt +1 -0
dml_code/__init__.py
ADDED
dml_code/cli.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Terminal entry point for YAML experiments."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from dml_code.estimation import estimate
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def cli() -> None:
|
|
10
|
+
parser = argparse.ArgumentParser()
|
|
11
|
+
parser.add_argument("config_path", type=Path, help="YAML experiment configuration file")
|
|
12
|
+
args = parser.parse_args()
|
|
13
|
+
estimate(config_path=args.config_path)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
if __name__ == "__main__":
|
|
17
|
+
cli()
|
dml_code/estimation.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Run AIPW estimation for a YAML experiment config.
|
|
2
|
+
|
|
3
|
+
Loads an experiment, expands its registry names into runs, and for each run:
|
|
4
|
+
prepares data, fits cross-fitted nuisance models, computes ATT/AIPW estimates
|
|
5
|
+
(plus control placebos), and saves logs. Groups are separate runs: a group_col
|
|
6
|
+
in the config fans out into one run per group value, each fit and logged on only
|
|
7
|
+
its own rows, plus the all-data run.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from dml_code.src.config import Run, get_experiments, unpack_runs, validate_runs
|
|
14
|
+
from dml_code.src.nuisance import fit_nuisances
|
|
15
|
+
from dml_code.src.estimate_helpers import (
|
|
16
|
+
calculate_control_placebo_estimates, calculate_control_placebo_predictions,
|
|
17
|
+
calculate_estimates, prepare_estimation_data,
|
|
18
|
+
)
|
|
19
|
+
from dml_code.src.results import (
|
|
20
|
+
build_summary_row, delete_df, prediction_columns, rebuild_master_summary,
|
|
21
|
+
save_df, save_models, clear_control_placebo_outputs,
|
|
22
|
+
)
|
|
23
|
+
from dml_code.src.plotting import generate_plots
|
|
24
|
+
from dml_code.src.utils import log_process_resources, time_elapsed, trim_memory
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def estimate(config_path: str | Path) -> None:
|
|
28
|
+
"""Run all expanded estimation runs from one external YAML config."""
|
|
29
|
+
|
|
30
|
+
config_path = Path(config_path).resolve()
|
|
31
|
+
experiment_name = config_path.stem
|
|
32
|
+
experiments, registries, output_dir = get_experiments(config_path)
|
|
33
|
+
runs = unpack_runs(experiments, registries)
|
|
34
|
+
validate_runs(runs)
|
|
35
|
+
|
|
36
|
+
stop_resource_logging = log_process_resources(interval=5)
|
|
37
|
+
failures: list[tuple[int, str]] = []
|
|
38
|
+
try:
|
|
39
|
+
for run_number, run in enumerate(runs, start=1):
|
|
40
|
+
print(
|
|
41
|
+
f"\nRun {run_number}/{len(runs)}: {run.program_name}, "
|
|
42
|
+
f"outcome={run.outcome}, group={run.group_value or 'all'}"
|
|
43
|
+
)
|
|
44
|
+
try:
|
|
45
|
+
run_one(experiment_name, run, run_number, output_dir=output_dir)
|
|
46
|
+
print(f"Run {run_number}/{len(runs)} complete.\n")
|
|
47
|
+
except Exception as error:
|
|
48
|
+
# Isolate failures so one bad run doesn't discard the others.
|
|
49
|
+
print(f"Run {run_number}/{len(runs)} FAILED: {error}\n")
|
|
50
|
+
failures.append((run_number, str(error)))
|
|
51
|
+
trim_memory()
|
|
52
|
+
finally:
|
|
53
|
+
stop_resource_logging.set()
|
|
54
|
+
|
|
55
|
+
# Rebuild the Excel master once from summaries in this output directory.
|
|
56
|
+
rebuild_master_summary(output_dir=output_dir)
|
|
57
|
+
|
|
58
|
+
if failures:
|
|
59
|
+
print(f"\n{len(failures)} run(s) failed:")
|
|
60
|
+
for run_number, error in failures:
|
|
61
|
+
print(f" Run {run_number}: {error}")
|
|
62
|
+
raise RuntimeError(f"{len(failures)} estimation run(s) failed; see errors above")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def run_one(experiment_name: str, run: Run, run_number: int, *, output_dir: Path) -> None:
|
|
66
|
+
"""Estimate, summarize, and save one run."""
|
|
67
|
+
|
|
68
|
+
start = time.time()
|
|
69
|
+
df, num_nulls_dropped = prepare_estimation_data(run)
|
|
70
|
+
|
|
71
|
+
print("Begin nuisance prediction")
|
|
72
|
+
estimate_start = time.time()
|
|
73
|
+
nuisance = fit_nuisances(run, df, run_number)
|
|
74
|
+
estimates = calculate_estimates(nuisance.predictions, run)
|
|
75
|
+
|
|
76
|
+
save_models(nuisance.models, experiment_name, run_number, output_dir=output_dir)
|
|
77
|
+
save_df("predictions", prediction_columns(nuisance.predictions, run),
|
|
78
|
+
experiment_name, run_number, output_dir=output_dir)
|
|
79
|
+
|
|
80
|
+
placebo_ready = False
|
|
81
|
+
rct_status = "not_applicable"
|
|
82
|
+
rct_error = None
|
|
83
|
+
# Explicit nulls replace any estimates from an earlier successful RCT run.
|
|
84
|
+
estimates.update({name: None for name in (
|
|
85
|
+
"placebo_aipw_att", "placebo_ipw_att", "placebo_outcomes_att", "placebo_treated_mean"
|
|
86
|
+
)})
|
|
87
|
+
try:
|
|
88
|
+
clear_control_placebo_outputs(experiment_name, run_number, output_dir=output_dir)
|
|
89
|
+
if run.calculate_on_control_placebo:
|
|
90
|
+
placebo_predictions = calculate_control_placebo_predictions(
|
|
91
|
+
run, nuisance.models, run_number
|
|
92
|
+
)
|
|
93
|
+
placebo_estimates = calculate_control_placebo_estimates(placebo_predictions, estimates, run)
|
|
94
|
+
save_df(
|
|
95
|
+
"control_placebo_predictions", placebo_predictions,
|
|
96
|
+
experiment_name, run_number, output_dir=output_dir,
|
|
97
|
+
)
|
|
98
|
+
estimates.update(placebo_estimates)
|
|
99
|
+
placebo_ready = True
|
|
100
|
+
rct_status = "success"
|
|
101
|
+
except Exception as error:
|
|
102
|
+
rct_status = "failed"
|
|
103
|
+
rct_error = str(error)
|
|
104
|
+
print(f"RCT-control placebo evaluation failed: {error}")
|
|
105
|
+
|
|
106
|
+
end = time.time()
|
|
107
|
+
summary = build_summary_row(
|
|
108
|
+
experiment_name, run, run_number,
|
|
109
|
+
estimates=estimates,
|
|
110
|
+
predictions=nuisance.predictions,
|
|
111
|
+
nuisance=nuisance,
|
|
112
|
+
num_nulls_dropped=num_nulls_dropped,
|
|
113
|
+
estimate_run_time=time_elapsed(estimate_start, end),
|
|
114
|
+
total_run_time=time_elapsed(start, end),
|
|
115
|
+
output_dir=output_dir,
|
|
116
|
+
rct_status=rct_status,
|
|
117
|
+
rct_error=rct_error,
|
|
118
|
+
)
|
|
119
|
+
save_df("summary", summary, experiment_name, output_dir=output_dir)
|
|
120
|
+
print(f"AIPW ATT: {estimates['aipw_att']:,.2f}")
|
|
121
|
+
|
|
122
|
+
generate_plots(experiment_name, run_number, run.covariate_set, output_dir=output_dir,
|
|
123
|
+
include_control_placebo=placebo_ready, predictions=nuisance.predictions,
|
|
124
|
+
control_placebo_predictions=placebo_predictions if placebo_ready else None)
|
|
125
|
+
if run.delete_raw_predictions:
|
|
126
|
+
delete_df("predictions", experiment_name, run_number, output_dir=output_dir)
|
|
127
|
+
if run.calculate_on_control_placebo:
|
|
128
|
+
delete_df("control_placebo_predictions", experiment_name, run_number, output_dir=output_dir)
|
dml_code/src/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Configuration, estimation helpers, persistence and diagnostics."""
|
dml_code/src/config.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""YAML experiments, Python registries, run expansion and validation."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, fields
|
|
4
|
+
import importlib
|
|
5
|
+
from itertools import product
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import sys
|
|
8
|
+
import types
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import polars as pl
|
|
12
|
+
from sklearn.base import clone
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
from dml_code.src.utils import JOIN_KEY, TREATMENT_COL
|
|
16
|
+
from dml_code.src.preprocessing import model_family, validate_model
|
|
17
|
+
|
|
18
|
+
EXPERIMENT_DEFAULTS: dict[str, list] = {
|
|
19
|
+
"outcomes_tune": [False],
|
|
20
|
+
"outcomes_tuning_trials": [None],
|
|
21
|
+
"propensity_tune": [False],
|
|
22
|
+
"propensity_tuning_trials": [None],
|
|
23
|
+
"group_col": [None],
|
|
24
|
+
"delete_raw_predictions": [False],
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Experiment:
|
|
30
|
+
"""One YAML config block of registry name lists; runs are their cross product."""
|
|
31
|
+
|
|
32
|
+
program_name: list[str]
|
|
33
|
+
outcomes: list[str]
|
|
34
|
+
covariate_set_name: list[str]
|
|
35
|
+
filter_set_name: list[str]
|
|
36
|
+
outcomes_model_name: list[str]
|
|
37
|
+
propensity_model_name: list[str]
|
|
38
|
+
outcomes_tune: list[bool]
|
|
39
|
+
outcomes_tuning_trials: list[int | None]
|
|
40
|
+
propensity_tune: list[bool]
|
|
41
|
+
propensity_tuning_trials: list[int | None]
|
|
42
|
+
group_col: list[str | None]
|
|
43
|
+
delete_raw_predictions: list[bool]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Run:
|
|
48
|
+
"""Concrete estimation run after resolving experiment names."""
|
|
49
|
+
|
|
50
|
+
program_name: str
|
|
51
|
+
data_path: Path
|
|
52
|
+
control_placebo_path: Path | None
|
|
53
|
+
join_key: str
|
|
54
|
+
treatment: str
|
|
55
|
+
outcome: str
|
|
56
|
+
covariate_set: list[str]
|
|
57
|
+
filter_set: list[Any]
|
|
58
|
+
covariate_set_name: str
|
|
59
|
+
filter_set_name: str
|
|
60
|
+
outcomes_model_name: str
|
|
61
|
+
propensity_model_name: str
|
|
62
|
+
outcomes_tune: bool
|
|
63
|
+
outcomes_tuning_trials: int | None
|
|
64
|
+
propensity_tune: bool
|
|
65
|
+
propensity_tuning_trials: int | None
|
|
66
|
+
outcomes_model: Any
|
|
67
|
+
propensity_model: Any
|
|
68
|
+
calculate_on_control_placebo: bool
|
|
69
|
+
# group_col is the column we split on; group_value is this run's slice of it
|
|
70
|
+
# (None means the all-data run). Each group value is its own run, fit and
|
|
71
|
+
# logged on only its rows.
|
|
72
|
+
group_col: str | None
|
|
73
|
+
group_value: Any
|
|
74
|
+
delete_raw_predictions: bool
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_experiments(config_path: str | Path) -> tuple[list[Experiment], dict, Path]:
|
|
78
|
+
"""Load a YAML experiment and the fixed Python registry dictionaries."""
|
|
79
|
+
config_path = Path(config_path).resolve()
|
|
80
|
+
if not config_path.is_file():
|
|
81
|
+
raise FileNotFoundError(f"Experiment config does not exist: {config_path}")
|
|
82
|
+
if config_path.suffix.lower() not in {".yaml", ".yml"}:
|
|
83
|
+
raise ValueError("Experiment config must be a YAML file (.yaml or .yml)")
|
|
84
|
+
with config_path.open() as stream:
|
|
85
|
+
config = yaml.safe_load(stream)
|
|
86
|
+
if not isinstance(config, dict):
|
|
87
|
+
raise ValueError("YAML config must be a dictionary")
|
|
88
|
+
unknown = sorted(set(config) - {"registry_dir", "output_dir", "experiments"})
|
|
89
|
+
if unknown:
|
|
90
|
+
raise ValueError(f"Unknown top-level YAML keys: {unknown}")
|
|
91
|
+
for key in ("registry_dir", "output_dir"):
|
|
92
|
+
if not isinstance(config.get(key), str) or not config[key].strip():
|
|
93
|
+
raise ValueError(f"YAML config must define a non-empty {key} path")
|
|
94
|
+
registry_dir = Path(config["registry_dir"]).expanduser()
|
|
95
|
+
if not registry_dir.is_absolute():
|
|
96
|
+
registry_dir = config_path.parent / registry_dir
|
|
97
|
+
registry_dir = registry_dir.resolve()
|
|
98
|
+
registries = _load_registries(registry_dir)
|
|
99
|
+
output_dir = Path(config["output_dir"]).expanduser()
|
|
100
|
+
if not output_dir.is_absolute():
|
|
101
|
+
output_dir = config_path.parent / output_dir
|
|
102
|
+
|
|
103
|
+
raw_blocks = config.get("experiments")
|
|
104
|
+
if not isinstance(raw_blocks, list) or not raw_blocks:
|
|
105
|
+
raise ValueError("experiments must be a non-empty list of dictionaries")
|
|
106
|
+
field_names = {field.name for field in fields(Experiment)}
|
|
107
|
+
experiments = []
|
|
108
|
+
for raw in raw_blocks:
|
|
109
|
+
if not isinstance(raw, dict):
|
|
110
|
+
raise ValueError("Each experiments block must be a dictionary")
|
|
111
|
+
unknown = sorted(set(raw) - field_names)
|
|
112
|
+
missing = sorted(field_names - set(raw) - set(EXPERIMENT_DEFAULTS))
|
|
113
|
+
if unknown or missing:
|
|
114
|
+
raise ValueError(
|
|
115
|
+
f"Experiment '{config_path.stem}': missing required keys {missing}, "
|
|
116
|
+
f"unknown keys {unknown}"
|
|
117
|
+
)
|
|
118
|
+
values = {**EXPERIMENT_DEFAULTS, **raw}
|
|
119
|
+
if values["group_col"] == []:
|
|
120
|
+
values["group_col"] = [None]
|
|
121
|
+
for name, value in values.items():
|
|
122
|
+
if not isinstance(value, list) or not value:
|
|
123
|
+
raise ValueError(f"{name} must be a non-empty list")
|
|
124
|
+
for learner in ("outcomes", "propensity"):
|
|
125
|
+
switches = values[f"{learner}_tune"]
|
|
126
|
+
budgets = values[f"{learner}_tuning_trials"]
|
|
127
|
+
if any(type(value) is not bool for value in switches):
|
|
128
|
+
raise ValueError(f"{learner}_tune must contain booleans")
|
|
129
|
+
if True in switches:
|
|
130
|
+
if any(type(value) is not int or value <= 0 for value in budgets):
|
|
131
|
+
raise ValueError(f"{learner}_tuning_trials must contain positive integers when tuning")
|
|
132
|
+
elif f"{learner}_tuning_trials" in raw:
|
|
133
|
+
raise ValueError(f"{learner}_tuning_trials is unused when {learner}_tune is false")
|
|
134
|
+
experiments.append(Experiment(**values))
|
|
135
|
+
return experiments, registries, output_dir.resolve()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _tuning_options(switches: list[bool], budgets: list[int | None]) -> list[tuple]:
|
|
139
|
+
"""Expand tuning budgets only for tuned runs, not repeated preset runs."""
|
|
140
|
+
return list(dict.fromkeys(
|
|
141
|
+
(tune, budget)
|
|
142
|
+
for tune in switches
|
|
143
|
+
for budget in (budgets if tune else [None])
|
|
144
|
+
))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def unpack_runs(experiments: list[Experiment], registries: dict) -> list[Run]:
|
|
148
|
+
"""Resolve supplied registry names and expand experiments into concrete runs."""
|
|
149
|
+
programs = registries["PROGRAM_REGISTRY"]
|
|
150
|
+
covariate_sets = registries["COVARIATE_SET_REGISTRY"]
|
|
151
|
+
filter_sets = registries["FILTER_SET_REGISTRY"]
|
|
152
|
+
outcomes_models = registries["OUTCOMES_MODEL_REGISTRY"]
|
|
153
|
+
propensity_models = registries["PROPENSITY_MODEL_REGISTRY"]
|
|
154
|
+
|
|
155
|
+
runs = []
|
|
156
|
+
for experiment in experiments:
|
|
157
|
+
for names, registry, label in (
|
|
158
|
+
(experiment.program_name, programs, "program"),
|
|
159
|
+
(experiment.covariate_set_name, covariate_sets, "covariate set"),
|
|
160
|
+
(experiment.filter_set_name, filter_sets, "filter set"),
|
|
161
|
+
(experiment.outcomes_model_name, outcomes_models, "outcomes model"),
|
|
162
|
+
(experiment.propensity_model_name, propensity_models, "propensity model"),
|
|
163
|
+
):
|
|
164
|
+
missing = [name for name in names if name not in registry]
|
|
165
|
+
if missing:
|
|
166
|
+
raise ValueError(f"Unknown {label} names: {missing}")
|
|
167
|
+
combos = product(
|
|
168
|
+
experiment.program_name, experiment.outcomes,
|
|
169
|
+
experiment.covariate_set_name, experiment.filter_set_name,
|
|
170
|
+
experiment.outcomes_model_name, experiment.propensity_model_name,
|
|
171
|
+
_tuning_options(experiment.outcomes_tune, experiment.outcomes_tuning_trials),
|
|
172
|
+
_tuning_options(experiment.propensity_tune, experiment.propensity_tuning_trials),
|
|
173
|
+
experiment.group_col, experiment.delete_raw_predictions,
|
|
174
|
+
)
|
|
175
|
+
for (
|
|
176
|
+
program_name, outcome, covariate_set_name, filter_set_name,
|
|
177
|
+
outcomes_model_name, propensity_model_name, outcomes_tuning,
|
|
178
|
+
propensity_tuning,
|
|
179
|
+
group_col, delete_raw_predictions,
|
|
180
|
+
) in combos:
|
|
181
|
+
program = programs[program_name]
|
|
182
|
+
if not isinstance(program, dict):
|
|
183
|
+
raise ValueError(f"Program {program_name!r} must be a dictionary")
|
|
184
|
+
if not isinstance(program.get("program_is_rct"), bool):
|
|
185
|
+
raise ValueError(f"Program {program_name!r} needs program_is_rct=True or False")
|
|
186
|
+
if "program_output_path" not in program:
|
|
187
|
+
raise ValueError(f"Program {program_name!r} needs program_output_path")
|
|
188
|
+
folder = Path(program["program_output_path"]).expanduser()
|
|
189
|
+
if not folder.is_absolute():
|
|
190
|
+
raise ValueError(
|
|
191
|
+
f"Program {program_name!r}: program_output_path must be absolute; "
|
|
192
|
+
"use Path(__file__).resolve().parent in the shared registry for relative locations"
|
|
193
|
+
)
|
|
194
|
+
data_path = folder.resolve() / "processed_data.parquet"
|
|
195
|
+
control_placebo_path = (
|
|
196
|
+
folder.resolve() / "rct_control_set_aside.parquet"
|
|
197
|
+
if program["program_is_rct"] else None
|
|
198
|
+
)
|
|
199
|
+
group_col = None if group_col in (None, "", False) else str(group_col)
|
|
200
|
+
required = [JOIN_KEY, TREATMENT_COL, outcome, *covariate_sets[covariate_set_name]]
|
|
201
|
+
if group_col:
|
|
202
|
+
required.append(group_col)
|
|
203
|
+
_validate_input_columns(data_path, required)
|
|
204
|
+
# Preserve current group discovery and the pooled run.
|
|
205
|
+
group_values = [None, *discover_group_values(data_path, group_col)]
|
|
206
|
+
for group_value in group_values:
|
|
207
|
+
runs.append(Run(
|
|
208
|
+
program_name=program_name,
|
|
209
|
+
data_path=data_path,
|
|
210
|
+
control_placebo_path=control_placebo_path,
|
|
211
|
+
join_key=JOIN_KEY,
|
|
212
|
+
treatment=TREATMENT_COL,
|
|
213
|
+
outcome=outcome,
|
|
214
|
+
covariate_set=covariate_sets[covariate_set_name],
|
|
215
|
+
filter_set=filter_sets[filter_set_name],
|
|
216
|
+
covariate_set_name=covariate_set_name,
|
|
217
|
+
filter_set_name=filter_set_name,
|
|
218
|
+
outcomes_model_name=outcomes_model_name,
|
|
219
|
+
propensity_model_name=propensity_model_name,
|
|
220
|
+
outcomes_tune=outcomes_tuning[0],
|
|
221
|
+
outcomes_tuning_trials=outcomes_tuning[1],
|
|
222
|
+
propensity_tune=propensity_tuning[0],
|
|
223
|
+
propensity_tuning_trials=propensity_tuning[1],
|
|
224
|
+
outcomes_model=clone(outcomes_models[outcomes_model_name]),
|
|
225
|
+
propensity_model=clone(propensity_models[propensity_model_name]),
|
|
226
|
+
calculate_on_control_placebo=program["program_is_rct"],
|
|
227
|
+
group_col=group_col,
|
|
228
|
+
group_value=group_value,
|
|
229
|
+
delete_raw_predictions=delete_raw_predictions,
|
|
230
|
+
))
|
|
231
|
+
return runs
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def discover_group_values(data_path: Path, group_col: str | None) -> list[Any]:
|
|
235
|
+
"""Non-null groups with at least 30 treated rows; pooled runs are separate."""
|
|
236
|
+
if not group_col:
|
|
237
|
+
return []
|
|
238
|
+
return (
|
|
239
|
+
pl.scan_parquet(data_path)
|
|
240
|
+
.filter(pl.col(TREATMENT_COL) == 1)
|
|
241
|
+
.select(group_col)
|
|
242
|
+
.drop_nulls()
|
|
243
|
+
.group_by(group_col)
|
|
244
|
+
.agg(pl.len().alias("n_obs"))
|
|
245
|
+
.filter(pl.col("n_obs") >= 30)
|
|
246
|
+
.select(group_col)
|
|
247
|
+
.collect().to_series().sort().to_list()
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _validate_input_columns(path: Path, required: list[str]) -> None:
|
|
252
|
+
"""Check files and columns before group discovery or model fitting."""
|
|
253
|
+
if not path.is_file():
|
|
254
|
+
raise FileNotFoundError(f"Input file does not exist: {path}")
|
|
255
|
+
schema = pl.scan_parquet(path).collect_schema()
|
|
256
|
+
missing = [name for name in dict.fromkeys(required) if name not in schema]
|
|
257
|
+
if missing:
|
|
258
|
+
raise ValueError(f"{path}: missing required columns {missing}")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def validate_runs(runs: list[Run]) -> None:
|
|
262
|
+
"""Validate required model types, input files and referenced columns."""
|
|
263
|
+
errors = []
|
|
264
|
+
for i, run in enumerate(runs):
|
|
265
|
+
label = f"Run #{i + 1} ({run.program_name}, {run.covariate_set_name}, {run.filter_set_name})"
|
|
266
|
+
for learner in ("outcomes", "propensity"):
|
|
267
|
+
model = getattr(run, f"{learner}_model")
|
|
268
|
+
try:
|
|
269
|
+
validate_model(model, learner)
|
|
270
|
+
if getattr(run, f"{learner}_tune") and model_family(model) == "ensemble":
|
|
271
|
+
raise ValueError("Ensembles use registry presets; ensemble tuning is not supported")
|
|
272
|
+
except ValueError as error:
|
|
273
|
+
errors.append(f"{label}: {error}")
|
|
274
|
+
required = [run.join_key, run.treatment, run.outcome, *run.covariate_set]
|
|
275
|
+
if run.group_col:
|
|
276
|
+
required.append(run.group_col)
|
|
277
|
+
paths = [run.data_path]
|
|
278
|
+
for path in paths:
|
|
279
|
+
try:
|
|
280
|
+
_validate_input_columns(path, required)
|
|
281
|
+
except (ValueError, FileNotFoundError) as error:
|
|
282
|
+
errors.append(f"{label}: {error}")
|
|
283
|
+
if errors:
|
|
284
|
+
raise ValueError("Run validation failed:\n\n" + "\n".join(errors))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _load_registries(registry_dir: Path) -> dict:
|
|
288
|
+
"""Load fixed registry files, isolating sibling imports between projects.
|
|
289
|
+
|
|
290
|
+
Both bare sibling imports and package-relative imports are supported.
|
|
291
|
+
Restore the caller's module cache and search path even if loading fails.
|
|
292
|
+
"""
|
|
293
|
+
if not registry_dir.is_dir():
|
|
294
|
+
raise FileNotFoundError(f"Registry directory does not exist: {registry_dir}")
|
|
295
|
+
registry_files = {
|
|
296
|
+
"programs": ("PROGRAM_REGISTRY",),
|
|
297
|
+
"covariate_sets": ("COVARIATE_SET_REGISTRY",),
|
|
298
|
+
"filter_sets": ("FILTER_SET_REGISTRY",),
|
|
299
|
+
"models": ("OUTCOMES_MODEL_REGISTRY", "PROPENSITY_MODEL_REGISTRY"),
|
|
300
|
+
}
|
|
301
|
+
package_name = registry_dir.name if registry_dir.name.isidentifier() else "_dml_registries"
|
|
302
|
+
local_names = {
|
|
303
|
+
path.stem for path in registry_dir.iterdir()
|
|
304
|
+
if path.suffix == ".py" or (path.is_dir() and (path / "__init__.py").is_file())
|
|
305
|
+
} | {package_name}
|
|
306
|
+
saved_modules = {
|
|
307
|
+
name: module for name, module in sys.modules.copy().items()
|
|
308
|
+
if name.split(".")[0] in local_names
|
|
309
|
+
}
|
|
310
|
+
old_path = sys.path.copy()
|
|
311
|
+
for name in saved_modules:
|
|
312
|
+
del sys.modules[name]
|
|
313
|
+
try:
|
|
314
|
+
sys.path.insert(0, str(registry_dir))
|
|
315
|
+
# An explicit package supports relative imports even without __init__.py.
|
|
316
|
+
package = types.ModuleType(package_name)
|
|
317
|
+
package.__path__ = [str(registry_dir)]
|
|
318
|
+
package.__package__ = package_name
|
|
319
|
+
sys.modules[package_name] = package
|
|
320
|
+
registries = {}
|
|
321
|
+
for filename, names in registry_files.items():
|
|
322
|
+
path = registry_dir / f"{filename}.py"
|
|
323
|
+
if not path.is_file():
|
|
324
|
+
raise FileNotFoundError(f"Missing registry file: {path}")
|
|
325
|
+
module = importlib.import_module(f"{package_name}.{filename}")
|
|
326
|
+
for name in names:
|
|
327
|
+
value = getattr(module, name, None)
|
|
328
|
+
if not isinstance(value, dict):
|
|
329
|
+
raise ValueError(f"{path.name} must expose a dictionary named {name}")
|
|
330
|
+
registries[name] = value
|
|
331
|
+
return registries
|
|
332
|
+
finally:
|
|
333
|
+
sys.path[:] = old_path
|
|
334
|
+
for name in list(sys.modules):
|
|
335
|
+
if name.split(".")[0] in local_names:
|
|
336
|
+
del sys.modules[name]
|
|
337
|
+
sys.modules.update(saved_modules)
|