# Author: Simon-Pierre Boucher — simon-pierre.boucher@uqo.ca """Arm errors against the realised price and against the resale-implied value (paper §sec:fundamental, Table tab:resale). Uses every live, real run under results/runs/ (run ids starting with `_` are mock/synthetic and skipped), restricted to the requested partitions (default: test and holdout, the inferential partitions) and to subsample properties with a later resale (results/fundamental/resale_targets.parquet from scripts/50_repeat_sales.py; pairs held < 12 months and flagged later legs excluded unless --keep-short / --keep-flagged). Outputs: results/fundamental/eval_vs_resale.csv (+ eval_vs_resale_contrasts.csv, eval_vs_resale.json) and the fragment paper/tables/tab_resale.tex. When no eligible run exists the script still runs, says so, and writes a fragment that reads NOT YET ESTIMATED (the resale-sample description table comes from scripts/50_repeat_sales.py). python3 scripts/51_eval_vs_resale.py # test + holdout python3 scripts/51_eval_vs_resale.py --splits validation # pilot runs (illustration only, not inferential) """ from __future__ import annotations import argparse import json import sys import time from datetime import datetime, timezone from pathlib import Path import numpy as np import pandas as pd import polars as pl from agentic_valuation.config import PAPER, PROCESSED, RESULTS, SEED from agentic_valuation.evaluation._common import prepare_truth from agentic_valuation.evaluation.tables import arm_pretty, escape, num, tabular from agentic_valuation.fundamental.resale_eval import PRICE_ARM, collect_live_runs, evaluate_vs_resale, select_main_config OUT = RESULTS / "fundamental" PRIMARY_PAIRS = [("D_avm", "G_debate_r2"), ("F_independent", "G_debate_r2"), ("J_cost_matched", "G_debate_r2"), ("I_placebo", "G_debate_r2")] HEADER = "% Author: Simon-Pierre Boucher — simon-pierre.boucher@uqo.ca\n% generated by scripts/51_eval_vs_resale.py — do not edit\n" def write_not_estimated(path: Path, reason: str) -> None: """Fragment with the table skeleton and a NOT YET ESTIMATED row; the full reason is kept in eval_vs_resale.json.""" path.parent.mkdir(parents=True, exist_ok=True) short = "no live run on the test/holdout partitions yet" if "none has predictions" in reason or "no live prediction run" in reason \ else "no evaluated property has a later resale in the snapshot" tex = (r"\begin{tabular}{@{}lrrrrrr@{}}" "\n" r"\toprule" "\n" r"Arm & {N} & {Mean $|e^{P}|$} & {Mean $|e^{R}|$} & {$\Delta$} & {95\% CI} & {$p$ (Holm)} \\" "\n" r"\midrule" "\n" r"\multicolumn{7}{@{}l}{\textbf{NOT YET ESTIMATED} --- " + escape(short) + r"} \\" "\n" r"\bottomrule" "\n" r"\end{tabular}") path.write_text(HEADER + tex + "\n", encoding="utf-8") def write_table(summary: pd.DataFrame, contrasts: pd.DataFrame, path: Path) -> None: c = contrasts[(contrasts["arm_a"] == PRICE_ARM) & (contrasts["metric"] == "mean_abs_log_error")].set_index("arm_b") header = ["Arm", "{N}", "{Mean $|e^{P}|$}", "{Mean $|e^{R}|$}", "{$\\Delta$}", "{95\\% CI}", "{$p$ (Holm)}"] rows = [] for _, r in summary.sort_values("arm").iterrows(): arm = r["arm"] if arm != PRICE_ARM and r["n"] < 30: continue # arms with fewer than 30 resale-linked properties are not tabulated (LLM subsamples: deferred test) label = "Realised price $P_{t_1}$" if arm == PRICE_ARM else arm_pretty(arm) if arm == PRICE_ARM: rows.append([label, num(r["n"], 0), "{--}", num(r["mean_abs_log_error_resale"]), "{--}", "{--}", "{--}"]) continue cc = c.loc[arm] if arm in c.index else None rows.append([label, num(r["n"], 0), num(r["mean_abs_log_error_price"]), num(r["mean_abs_log_error_resale"]), num(cc["diff"]) if cc is not None else "{--}", f"[{cc['ci_lo']:.3f}, {cc['ci_hi']:.3f}]" if cc is not None and np.isfinite(cc["ci_lo"]) else "{--}", num(cc["p_holm"], 3) if cc is not None else "{--}"]) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(HEADER + tabular(header, rows, "@{}lrrrrrr@{}", midrules_after=[0]) + "\n", encoding="utf-8") def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--runs-dir", default=str(RESULTS / "runs")) ap.add_argument("--truth", default=str(PROCESSED / "analytic.parquet")) ap.add_argument("--splits-file", default=str(PROCESSED / "splits.parquet")) ap.add_argument("--targets", default=str(OUT / "resale_targets.parquet")) ap.add_argument("--splits", default="test,holdout", help="comma-separated partitions to evaluate") ap.add_argument("--keep-short", action="store_true", help="keep pairs held < 12 months") ap.add_argument("--keep-flagged", action="store_true", help="keep pairs whose later leg has a data-quality flag") ap.add_argument("--B", type=int, default=2000) ap.add_argument("--seed", type=int, default=SEED) ap.add_argument("--main-committee", default="mixed_random") ap.add_argument("--main-aggregation", default="median") ap.add_argument("--include-fake", action="store_true") ap.add_argument("--out", default=str(OUT)) ap.add_argument("--tex", default=str(PAPER / "tables" / "tab_resale.tex")) args = ap.parse_args(argv) out, tex = Path(args.out), Path(args.tex) out.mkdir(parents=True, exist_ok=True) t0 = time.time() splits = [s.strip() for s in args.splits.split(",") if s.strip()] tp = Path(args.targets) if not tp.exists(): print(f"missing {tp}: run scripts/50_repeat_sales.py first", file=sys.stderr) return 1 targets = pd.read_parquet(tp) preds, used = collect_live_runs(Path(args.runs_dir), include_fake=args.include_fake) status: dict = {"splits": splits, "runs_found": used, "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds")} if preds.empty: reason = f"no live prediction run under results/runs (runs starting with `_` are mock and skipped)" print(reason) write_not_estimated(tex, reason) status["status"] = "not_estimated" status["reason"] = reason (out / "eval_vs_resale.json").write_text(json.dumps(status, indent=2), encoding="utf-8") return 0 preds = preds[preds["split"].astype(str).isin(splits)].copy() if preds.empty: reason = f"live runs exist ({', '.join(used)}) but none has predictions on the partitions {splits}" print(reason) write_not_estimated(tex, reason) status["status"] = "not_estimated" status["reason"] = reason (out / "eval_vs_resale.json").write_text(json.dumps(status, indent=2), encoding="utf-8") return 0 truth = prepare_truth(pl.read_parquet(args.truth)) sp = Path(args.splits_file) if sp.exists(): truth = truth.merge(pl.read_parquet(sp).select(["tx_id", "split"]).to_pandas(), on="tx_id", how="left") truth = truth[truth["split"].astype(str).isin(splits)] main = select_main_config(preds, committee=args.main_committee, aggregation=args.main_aggregation) n_pred_tx = main["tx_id"].nunique() summary, contrasts = evaluate_vs_resale(main, truth, targets, exclude_short=not args.keep_short, exclude_flagged=not args.keep_flagged, B=args.B, seed=args.seed, arm_pairs=PRIMARY_PAIRS) if summary.empty: reason = f"runs {', '.join(used)} cover {n_pred_tx} properties on {splits} but none has a later resale in the snapshot" print(reason) write_not_estimated(tex, reason) status.update({"status": "not_estimated", "reason": reason, "n_predicted_properties": int(n_pred_tx)}) (out / "eval_vs_resale.json").write_text(json.dumps(status, indent=2), encoding="utf-8") return 0 summary.insert(0, "splits", "+".join(splits)) summary.to_csv(out / "eval_vs_resale.csv", index=False) contrasts.to_csv(out / "eval_vs_resale_contrasts.csv", index=False) write_table(summary, contrasts, tex) status.update({"status": "estimated", "n_predicted_properties": int(n_pred_tx), "n_with_resale": int(summary["n"].max()), "arms": summary["arm"].tolist(), "B": args.B, "exclude_short": not args.keep_short, "exclude_flagged": not args.keep_flagged, "seconds": round(time.time() - t0, 1)}) (out / "eval_vs_resale.json").write_text(json.dumps(status, indent=2), encoding="utf-8") print(f"runs: {used}; partitions {splits}; predicted properties {n_pred_tx}; with resale target {int(summary['n'].max())}") print(summary.round(4).to_string(index=False)) print(contrasts[contrasts["metric"] == "mean_abs_log_error"][["arm_a", "arm_b", "n", "value_a", "value_b", "diff", "ci_lo", "ci_hi", "p_boot", "p_holm"]].round(4).to_string(index=False)) return 0 if __name__ == "__main__": sys.exit(main())