1#!/usr/bin/env python32# Author: Simon-Pierre Boucher — simon-pierre.boucher@uqo.ca3"""Phase 3 — fit the non-LLM baselines (A hedonic, B hierarchical/repeat-sales, C LightGBM [+CatBoost/XGBoost],4D AVM with conformal quantiles) on train, tune on validation, and write prediction records for the validation,5test and holdout partitions (full partitions) plus the spatial-holdout experiment.67Usage: python3 scripts/10_train_baselines.py [--synthetic] [--trials 12] [--skip-robustness]8Outputs: results/runs/baselines[_synthetic]/predictions.parquet, manifest.json, search logs.9"""10from __future__ import annotations1112import argparse13import json14import platform15import sys16import time17from pathlib import Path1819import numpy as np20import polars as pl2122sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))23from agentic_valuation.config import PROCESSED, RESULTS, SYNTHETIC, load_config # noqa: E40224from agentic_valuation.baselines.gbm import AVM, CatBoostModel, LightGBMModel, XGBoostModel, dump_search_log # noqa: E40225from agentic_valuation.baselines.hedonic import HedonicRidge, HierarchicalHedonic # noqa: E402262728def main(argv: list[str] | None = None) -> None:29 ap = argparse.ArgumentParser()30 ap.add_argument("--synthetic", action="store_true")31 ap.add_argument("--trials", type=int, default=12)32 ap.add_argument("--skip-robustness", action="store_true")33 ap.add_argument("--max-train", type=int, default=0, help="subsample train rows (speed; 0 = all)")34 a = ap.parse_args(argv)35 base = SYNTHETIC if a.synthetic else PROCESSED36 run_id = "baselines_synthetic" if a.synthetic else "baselines"37 out = RESULTS / "runs" / run_id38 out.mkdir(parents=True, exist_ok=True)39 fm = pl.read_parquet(base / "feature_matrix.parquet")40 if "split" not in fm.columns:41 fm = fm.join(pl.read_parquet(base / "splits.parquet").select(["tx_id", "split"]), on="tx_id", how="left")42 spatial_regions = load_config("data")["spatial_holdout"]["regions"]43 train = fm.filter(pl.col("split") == "train")44 if a.max_train and train.height > a.max_train:45 train = train.sample(a.max_train, seed=20261027)46 valid = fm.filter(pl.col("split") == "validation")47 evals = {s: fm.filter(pl.col("split") == s) for s in ("validation", "test", "holdout")}48 print(f"train {train.height:,} | valid {valid.height:,} | test {evals['test'].height:,} | holdout {evals['holdout'].height:,}")4950 # Arm C = gradient boosting on attributes, roll values and location (no as-of market / neighbourhood lag features);51 # arm D = AVM with the full spatial-temporal feature set + quantile models + conformal scaling.52 LAGS = ("mun_", "nb_")53 models = {54 "A_hedonic": lambda: HedonicRidge(),55 "B_hierarchical": lambda: HierarchicalHedonic(),56 "C_gbm": lambda: LightGBMModel(n_trials=a.trials, exclude_prefixes=LAGS),57 "D_avm": lambda: AVM(n_trials=a.trials),58 }59 if not a.skip_robustness:60 models["C_catboost"] = lambda: CatBoostModel(exclude_prefixes=LAGS)61 models["C_xgboost"] = lambda: XGBoostModel(exclude_prefixes=LAGS)62 records, manifest = [], {"run_id": run_id, "synthetic": a.synthetic, "python": platform.python_version(), "machine": platform.machine(), "arms": {}}63 for arm, ctor in models.items():64 t0 = time.perf_counter()65 m = ctor().fit(train, valid)66 fit_s = time.perf_counter() - t067 info = {"fit_seconds": round(fit_s, 1)}68 for split, df in evals.items():69 if df.height == 0:70 continue71 t1 = time.perf_counter()72 pred = m.predict(df)73 records.append(pred.to_records(split, arm, run_id, latency_s=time.perf_counter() - t1))74 if isinstance(m, LightGBMModel):75 dump_search_log(m, out / f"{arm}_search.json")76 info["params"] = m.params77 if isinstance(m, AVM):78 dump_search_log(m.point, out / f"{arm}_search.json")79 info["params"] = m.point.params80 info["conformal_scale"] = m.scale81 if isinstance(m, HierarchicalHedonic):82 info["w_repeat"] = m.w_repeat83 manifest["arms"][arm] = info84 print(f"{arm}: fit {fit_s:.0f}s {info}")85 # spatial holdout: retrain A, C, D without the withheld regions; predict test rows of those regions86 sp_train = train.filter(~pl.col("region_name").is_in(spatial_regions))87 sp_valid = valid.filter(~pl.col("region_name").is_in(spatial_regions))88 sp_test = evals["test"].filter(pl.col("region_name").is_in(spatial_regions))89 if sp_test.height > 0 and sp_train.height > 1000:90 for arm in ("A_hedonic", "C_gbm", "D_avm"):91 m = models[arm]().fit(sp_train, sp_valid)92 records.append(m.predict(sp_test).to_records("test", f"{arm}_spatial", run_id))93 manifest["spatial_holdout"] = {"regions": spatial_regions, "n_test": sp_test.height, "n_train": sp_train.height}94 preds = pl.concat(records)95 preds.write_parquet(out / "predictions.parquet")96 (out / "manifest.json").write_text(json.dumps(manifest, indent=1, default=float))97 # quick in-sample-free summary (validation/test mean abs log error) for the console98 truth = fm.select(["tx_id", "log_price"])99 s = preds.join(truth, on="tx_id").with_columns((pl.col("point").log() - pl.col("log_price")).abs().alias("ae")).group_by(["arm", "split"]).agg(pl.col("ae").mean().round(4), pl.len()).sort(["split", "arm"])100 print(s)101102103if __name__ == "__main__":104 main()105