Skip to content

Commit 8828552

Browse files
shap
1 parent d531662 commit 8828552

5 files changed

Lines changed: 1084 additions & 0 deletions

File tree

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
#!/usr/bin/env python3
2+
"""Retrain walk-forward models and compute SHAP feature importance.
3+
4+
Designed for maize NL family representatives (RF, Transformer, TabPFN) but
5+
general enough for any model listed in ``MODEL_MANIFEST``.
6+
7+
Example (cluster)::
8+
9+
poetry run python cybench/runs/analysis/compute_shap_importance.py \\
10+
--crop maize --country NL \\
11+
--models random_forest,transformer_lf,tabpfn \\
12+
--baselines-dir /lustre/backup/SHARED/AIN/agml/output/baselines_NL_eos_v2 \\
13+
--output-dir /lustre/backup/SHARED/AIN/agml/output/shap_importance/maize_NL_eos \\
14+
--origins 2020
15+
16+
Quick local pilot (last origin only)::
17+
18+
poetry run python cybench/runs/analysis/compute_shap_importance.py \\
19+
--crop maize --country NL --models random_forest \\
20+
--baselines-dir ../output/baselines_NL_eos_v2 \\
21+
--output-dir ../output/shap_importance/maize_NL_eos \\
22+
--origins 2020 --force-cpu
23+
"""
24+
25+
from __future__ import annotations
26+
27+
import argparse
28+
import logging
29+
import sys
30+
from pathlib import Path
31+
32+
from cybench.runs.analysis.shap_importance_lib import (
33+
DEFAULT_MAIZE_FAMILY_MODELS,
34+
MODEL_MANIFEST,
35+
ShapRunSpec,
36+
aggregate_feature_importance,
37+
run_shap_case,
38+
)
39+
40+
log = logging.getLogger(__name__)
41+
42+
43+
def _parse_models(raw: str | None) -> list[str]:
44+
if not raw:
45+
return list(DEFAULT_MAIZE_FAMILY_MODELS)
46+
models = [part.strip() for part in raw.split(",") if part.strip()]
47+
unknown = [m for m in models if m not in MODEL_MANIFEST]
48+
if unknown:
49+
raise ValueError(
50+
f"Unknown model(s): {unknown}. Supported: {sorted(MODEL_MANIFEST)}"
51+
)
52+
return models
53+
54+
55+
def _parse_origins(raw: str | None, *, last_only: bool) -> tuple[int, ...] | None:
56+
if last_only:
57+
return None # resolved later from dataset years
58+
if not raw:
59+
return None
60+
return tuple(int(part.strip()) for part in raw.split(",") if part.strip())
61+
62+
63+
def main(argv: list[str] | None = None) -> int:
64+
parser = argparse.ArgumentParser(description=__doc__)
65+
parser.add_argument("--crop", default="maize")
66+
parser.add_argument("--country", default="NL")
67+
parser.add_argument(
68+
"--models",
69+
help=f"Comma-separated slugs (default: {','.join(DEFAULT_MAIZE_FAMILY_MODELS)})",
70+
)
71+
parser.add_argument("--horizon", default="eos")
72+
parser.add_argument("--seed", type=int, default=42)
73+
parser.add_argument(
74+
"--baselines-dir",
75+
type=Path,
76+
required=True,
77+
help="Batch folder with screening + walk-forward Hydra runs",
78+
)
79+
parser.add_argument(
80+
"--output-dir",
81+
type=Path,
82+
required=True,
83+
help="Directory for per-model SHAP YAML/CSV outputs",
84+
)
85+
parser.add_argument(
86+
"--origins",
87+
help="Comma-separated forecast years (default: all walk-forward origins)",
88+
)
89+
parser.add_argument(
90+
"--last-origin-only",
91+
action="store_true",
92+
help="Only compute SHAP for the latest walk-forward test year",
93+
)
94+
parser.add_argument("--max-background", type=int, default=50)
95+
parser.add_argument("--max-eval-samples", type=int, default=80)
96+
parser.add_argument(
97+
"--force-cpu",
98+
action="store_true",
99+
help="Override frozen CUDA configs (useful on login nodes)",
100+
)
101+
parser.add_argument("-v", "--verbose", action="store_true")
102+
args = parser.parse_args(argv)
103+
104+
logging.basicConfig(
105+
level=logging.DEBUG if args.verbose else logging.INFO,
106+
format="%(asctime)s %(levelname)s %(message)s",
107+
)
108+
109+
models = _parse_models(args.models)
110+
test_years = _parse_origins(args.origins, last_only=args.last_origin_only)
111+
args.output_dir.mkdir(parents=True, exist_ok=True)
112+
113+
if args.last_origin_only and test_years is None:
114+
from cybench.datasets.data_factory import DataFactory
115+
from cybench.util.config_utils import reload_config_with_overrides
116+
from cybench.runs.analysis.shap_importance_lib import (
117+
CONF_DIR,
118+
compose_dataset_overrides,
119+
iter_walk_forward_origins,
120+
)
121+
122+
probe_spec = ShapRunSpec(
123+
crop=args.crop,
124+
country=args.country,
125+
model=models[0],
126+
horizon=args.horizon,
127+
seed=args.seed,
128+
baselines_dir=args.baselines_dir,
129+
)
130+
meta = MODEL_MANIFEST[models[0]]
131+
overrides = compose_dataset_overrides(
132+
probe_spec,
133+
framework=str(meta["framework"]),
134+
feature_design=bool(meta["feature_design"]),
135+
)
136+
cfg = reload_config_with_overrides(
137+
CONF_DIR, "config", overrides=[f"model={models[0]}", *overrides]
138+
)
139+
years = DataFactory.peek_dataset_years(cfg.dataset)
140+
last_year = max(
141+
int(test[0])
142+
for _train, test in iter_walk_forward_origins(years, seed=args.seed)
143+
)
144+
test_years = (last_year,)
145+
log.info("Last-origin-only: using test year %s", last_year)
146+
147+
summaries: list[dict] = []
148+
for model in models:
149+
spec = ShapRunSpec(
150+
crop=args.crop,
151+
country=args.country,
152+
model=model,
153+
horizon=args.horizon,
154+
seed=args.seed,
155+
baselines_dir=args.baselines_dir,
156+
test_years=test_years,
157+
max_background=args.max_background,
158+
max_eval_samples=args.max_eval_samples,
159+
force_cpu=args.force_cpu,
160+
)
161+
model_out = args.output_dir / f"{args.crop}_{args.country}" / model
162+
model_out.mkdir(parents=True, exist_ok=True)
163+
summary = run_shap_case(spec, output_dir=model_out)
164+
summaries.append(summary)
165+
repro = summary["origins"][0]["reproduction"] if summary["origins"] else {}
166+
log.info(
167+
"[%s] origins=%d | reproduction corr=%s max_diff=%s",
168+
model,
169+
summary["n_origins"],
170+
repro.get("corr_saved_preds"),
171+
repro.get("max_abs_pred_diff"),
172+
)
173+
174+
all_records: list[dict] = []
175+
for summary in summaries:
176+
all_records.extend(summary["origins"])
177+
agg = aggregate_feature_importance(all_records)
178+
if not agg.empty:
179+
agg_path = args.output_dir / f"{args.crop}_{args.country}" / "shap_aggregate_all_models.csv"
180+
agg.to_csv(agg_path, index=False)
181+
log.info("Wrote aggregate table to %s", agg_path)
182+
183+
return 0
184+
185+
186+
if __name__ == "__main__":
187+
sys.exit(main())

0 commit comments

Comments
 (0)