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 ADDED
@@ -0,0 +1,5 @@
1
+ """Cross-fitted program evaluation from processed inputs."""
2
+
3
+ from dml_code.estimation import estimate
4
+
5
+ __all__ = ["estimate"]
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)
@@ -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)