221 lines
8 KiB
Python
221 lines
8 KiB
Python
"""Backtesting: Evaluate price index model on held-out recent sales.
|
|
|
|
Test set: properties with 2+ sales where the last sale is 2022-2025.
|
|
Uses the second-to-last sale as input, predicts the last sale price.
|
|
Compares index-based prediction against a naive baseline (raw input price).
|
|
Uses type-stratified index when available, falling back to "All" type.
|
|
|
|
Output: backtest_results.parquet with predictions vs actuals.
|
|
"""
|
|
|
|
import argparse
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import polars as pl
|
|
|
|
CURRENT_YEAR = 2025
|
|
TEST_YEAR_MIN = 2022
|
|
TERRACE_TYPES = ["Mid-Terrace", "End-Terrace", "Enclosed Mid-Terrace", "Enclosed End-Terrace"]
|
|
|
|
|
|
def type_group_expr():
|
|
return (
|
|
pl.when(pl.col("Property type").is_in(TERRACE_TYPES)).then(pl.lit("Terraced"))
|
|
.when(pl.col("Property type") == "Flats/Maisonettes").then(pl.lit("Flats"))
|
|
.when(pl.col("Property type").is_in(["Detached", "Semi-Detached"])).then(pl.col("Property type"))
|
|
.otherwise(pl.lit(None))
|
|
.alias("type_group")
|
|
)
|
|
|
|
|
|
def extract_test_set(input_path: Path) -> pl.DataFrame:
|
|
"""Extract test pairs: second-to-last sale as input, last sale as ground truth."""
|
|
print("Loading test set...")
|
|
df = (
|
|
pl.scan_parquet(input_path)
|
|
.select("Postcode", "historical_prices", "Property type")
|
|
.filter(
|
|
pl.col("Postcode").is_not_null(),
|
|
pl.col("historical_prices").list.len() >= 2,
|
|
)
|
|
.with_columns(
|
|
pl.col("Postcode").str.slice(0, pl.col("Postcode").str.len_chars() - 2).str.strip_chars().alias("sector"),
|
|
type_group_expr(),
|
|
# Last sale (ground truth)
|
|
pl.col("historical_prices").list.last().struct.field("year").alias("actual_year"),
|
|
pl.col("historical_prices").list.last().struct.field("price").alias("actual_price"),
|
|
# Second-to-last sale (input)
|
|
pl.col("historical_prices").list.get(-2).struct.field("year").alias("input_year"),
|
|
pl.col("historical_prices").list.get(-2).struct.field("price").alias("input_price"),
|
|
)
|
|
.filter(
|
|
pl.col("actual_year") >= TEST_YEAR_MIN,
|
|
pl.col("input_price") > 0,
|
|
pl.col("actual_price") > 0,
|
|
pl.col("actual_year") > pl.col("input_year"),
|
|
)
|
|
.collect()
|
|
)
|
|
print(f" {len(df):,} test pairs (last sale {TEST_YEAR_MIN}-{CURRENT_YEAR})")
|
|
return df
|
|
|
|
|
|
def predict(test: pl.DataFrame, index: pl.DataFrame) -> pl.DataFrame:
|
|
"""Index-based prediction with type-stratified fallback."""
|
|
has_type_group = "type_group" in index.columns
|
|
|
|
if has_type_group:
|
|
idx_typed = index.filter(pl.col("type_group") != "All")
|
|
idx_all = index.filter(pl.col("type_group") == "All")
|
|
|
|
# Join type-specific index at input year
|
|
test = test.join(
|
|
idx_typed.select("sector", "type_group", "year", pl.col("log_index").alias("li_in_typed")),
|
|
left_on=["sector", "type_group", "input_year"],
|
|
right_on=["sector", "type_group", "year"],
|
|
how="left",
|
|
)
|
|
# Join "All" index at input year
|
|
test = test.join(
|
|
idx_all.select("sector", "year", pl.col("log_index").alias("li_in_all")),
|
|
left_on=["sector", "input_year"],
|
|
right_on=["sector", "year"],
|
|
how="left",
|
|
)
|
|
# Join type-specific index at actual year
|
|
test = test.join(
|
|
idx_typed.select("sector", "type_group", "year", pl.col("log_index").alias("li_act_typed")),
|
|
left_on=["sector", "type_group", "actual_year"],
|
|
right_on=["sector", "type_group", "year"],
|
|
how="left",
|
|
)
|
|
# Join "All" index at actual year
|
|
test = test.join(
|
|
idx_all.select("sector", "year", pl.col("log_index").alias("li_act_all")),
|
|
left_on=["sector", "actual_year"],
|
|
right_on=["sector", "year"],
|
|
how="left",
|
|
)
|
|
|
|
test = test.with_columns(
|
|
pl.col("li_in_typed").fill_null(pl.col("li_in_all")).alias("log_index_input"),
|
|
pl.col("li_act_typed").fill_null(pl.col("li_act_all")).alias("log_index_actual"),
|
|
)
|
|
else:
|
|
# Unstratified index
|
|
test = test.join(
|
|
index.select("sector", "year", pl.col("log_index").alias("log_index_input")),
|
|
left_on=["sector", "input_year"],
|
|
right_on=["sector", "year"],
|
|
how="left",
|
|
)
|
|
test = test.join(
|
|
index.select("sector", "year", pl.col("log_index").alias("log_index_actual")),
|
|
left_on=["sector", "actual_year"],
|
|
right_on=["sector", "year"],
|
|
how="left",
|
|
)
|
|
|
|
test = test.with_columns(
|
|
(
|
|
pl.col("input_price").cast(pl.Float64)
|
|
* (pl.col("log_index_actual") - pl.col("log_index_input")).exp()
|
|
).fill_null(pl.col("input_price").cast(pl.Float64)).alias("predicted"),
|
|
)
|
|
return test
|
|
|
|
|
|
def compute_metrics(actual: np.ndarray, predicted: np.ndarray) -> dict:
|
|
valid = np.isfinite(predicted) & np.isfinite(actual) & (actual > 0)
|
|
actual = actual[valid]
|
|
predicted = predicted[valid]
|
|
|
|
ape = np.abs(predicted - actual) / actual
|
|
signed_err = predicted - actual
|
|
|
|
return {
|
|
"MdAPE (%)": float(np.median(ape) * 100),
|
|
"% within 10%": float(np.mean(ape <= 0.10) * 100),
|
|
"% within 20%": float(np.mean(ape <= 0.20) * 100),
|
|
"% within 30%": float(np.mean(ape <= 0.30) * 100),
|
|
"MAE (£)": float(np.mean(np.abs(signed_err))),
|
|
"Mean signed error (£)": float(np.mean(signed_err)),
|
|
"n": int(len(actual)),
|
|
}
|
|
|
|
|
|
def print_metrics_table(metrics_by_stage: dict):
|
|
print("\n" + "=" * 55)
|
|
print("BACKTEST RESULTS")
|
|
print("=" * 55)
|
|
|
|
metric_names = ["MdAPE (%)", "% within 10%", "% within 20%", "% within 30%", "MAE (£)", "Mean signed error (£)", "n"]
|
|
stages = list(metrics_by_stage.keys())
|
|
|
|
header = f"{'Metric':<25s}"
|
|
for stage in stages:
|
|
header += f" {stage:>14s}"
|
|
print(header)
|
|
print("-" * 55)
|
|
|
|
for metric in metric_names:
|
|
row = f"{metric:<25s}"
|
|
for stage in stages:
|
|
val = metrics_by_stage[stage][metric]
|
|
if metric == "n":
|
|
row += f" {val:>14,d}"
|
|
elif "£" in metric:
|
|
row += f" {val:>13,.0f}"
|
|
else:
|
|
row += f" {val:>13.1f}%"
|
|
print(row)
|
|
|
|
print("=" * 55)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Backtest price estimation model")
|
|
parser.add_argument("--input", type=Path, required=True, help="Path to wide.parquet")
|
|
parser.add_argument("--index", type=Path, required=True, help="Path to price_index.parquet")
|
|
parser.add_argument("--output", type=Path, required=True, help="Output backtest_results.parquet")
|
|
args = parser.parse_args()
|
|
|
|
index = pl.read_parquet(args.index)
|
|
has_type_group = "type_group" in index.columns
|
|
if has_type_group:
|
|
print(f"Price index: {len(index):,} rows, {index['sector'].n_unique():,} sectors, "
|
|
f"{index['type_group'].n_unique()} type groups")
|
|
else:
|
|
print(f"Price index: {len(index):,} rows, {index['sector'].n_unique():,} sectors")
|
|
|
|
test = extract_test_set(args.input)
|
|
|
|
print("\nPredicting with price index...")
|
|
test = predict(test, index)
|
|
|
|
# Compute and print metrics
|
|
actual = test["actual_price"].to_numpy().astype(np.float64)
|
|
metrics = {
|
|
"Naive": compute_metrics(actual, test["input_price"].to_numpy().astype(np.float64)),
|
|
"Index": compute_metrics(actual, test["predicted"].to_numpy().astype(np.float64)),
|
|
}
|
|
|
|
print_metrics_table(metrics)
|
|
|
|
# Save results
|
|
result = test.select(
|
|
"Postcode", "sector",
|
|
"input_year", "input_price",
|
|
"actual_year", "actual_price",
|
|
"predicted",
|
|
)
|
|
|
|
result.write_parquet(args.output)
|
|
size_mb = args.output.stat().st_size / (1024 * 1024)
|
|
print(f"\nWrote {args.output} ({size_mb:.1f} MB)")
|
|
print(f" {len(result):,} rows")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|