SPB Git

spb/qwhpi Public

QHPI — Quebec Housing Price Index: quality-adjusted, hierarchically pooled housing price indexes.

Python 63.9% TypeScript 25.4% CSS 5.5% TeX 3.5% SQL 0.8% Makefile 0.5% Dockerfile 0.5%
5.1 KB · 133 lines python
Raw Blame History
1#!/usr/bin/env python32# =============================================================================3# QWHPI — Quebec Weekly Housing Price Index4# Author  : Simon-Pierre Boucher5# Contact : contact@spboucher.ai6# File    : engine/scripts/02_geography.py7# Purpose : Step 2 — spatial join of all transactions to official boundaries,8#           validation against the raw city text field, persistence.9# =============================================================================10"""Geography build (Execution Order step 2).1112Runs the spatial join (cached in ``data/processed/geo_join.parquet``),13validates the result against the raw ``city`` text field, and writes14validation tables + a report section.15"""1617from __future__ import annotations1819import sys20import time21import unicodedata22from pathlib import Path2324sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))2526import polars as pl2728from qwhpi.config import REPORTS_DIR, TABLES_DIR, ensure_dirs29from qwhpi.geography import SDA_VERSION, build_geo_join30from qwhpi.ingest import load_raw3132LINES: list[str] = []333435def note(line: str = "") -> None:36    LINES.append(line)37    print(line)383940def norm_name(s: str) -> str:41    """Accent/case/punctuation-insensitive normalization for name comparison."""42    if s is None:43        return ""44    s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode()45    return (46        s.lower().replace("saint-", "st-").replace("sainte-", "ste-")47        .replace("'", "").replace("’", "").replace(".", "").replace(" ", "-")48    )495051def main() -> None:52    ensure_dirs()53    tx = load_raw()5455    t0 = time.time()56    geo = build_geo_join(tx)57    note("## Geography spatial join (SDA 1/20k, version " + SDA_VERSION + ")")58    note("")59    note(f"- Join computed/loaded in {time.time() - t0:.1f}s for {geo.height:,} transactions")6061    method_counts = geo.group_by("join_method").len().sort("len", descending=True)62    for row in method_counts.iter_rows(named=True):63        note(f"- join_method={row['join_method']}: {row['len']:,} "64             f"({row['len'] / geo.height * 100:.3f}%)")6566    n_regions = geo["region"].n_unique()67    n_munic = geo["geo_code"].n_unique()68    note(f"- Distinct regions matched: {n_regions} | municipalities: {n_munic:,}")6970    region_dist = (71        geo.group_by(["region_code", "region"]).len()72        .sort("len", descending=True)73        .rename({"len": "n_transactions"})74    )75    region_dist.write_csv(TABLES_DIR / "geo_region_distribution.csv")76    note("")77    note("Transactions by administrative region:")78    for row in region_dist.iter_rows(named=True):79        note(f"- {row['region_code']} {row['region']}: {row['n_transactions']:,}")8081    # ---------------------------------------------------------------- #82    # Validation vs raw city text field83    # ---------------------------------------------------------------- #84    joined = tx.select("id", "city").join(geo, on="id", how="left")85    comparable = joined.filter(pl.col("municipality").is_not_null())86    comp = comparable.with_columns(87        pl.col("city").map_elements(norm_name, return_dtype=pl.Utf8).alias("city_norm"),88        pl.col("municipality").map_elements(norm_name, return_dtype=pl.Utf8).alias("mun_norm"),89    ).with_columns(90        (91            (pl.col("city_norm") == pl.col("mun_norm"))92            | pl.col("mun_norm").str.contains(pl.col("city_norm"), literal=True)93            | pl.col("city_norm").str.contains(pl.col("mun_norm"), literal=True)94        ).alias("name_match")95    )96    match_rate = comp["name_match"].mean() * 10097    note("")98    note("### Validation: joined municipality vs raw `city` text")99    note(f"- Name agreement (normalized, containment-tolerant): {match_rate:.2f}%")100101    mismatches = (102        comp.filter(~pl.col("name_match"))103        .group_by(["city", "municipality", "region"]).len()104        .sort("len", descending=True)105        .rename({"len": "n"})106    )107    mismatches.head(300).write_csv(TABLES_DIR / "geo_city_mismatch_top300.csv")108    note(f"- Mismatching pairs saved: outputs/tables/geo_city_mismatch_top300.csv "109         f"({mismatches.height:,} distinct pairs, {mismatches['n'].sum():,} transactions)")110    note("")111    note("Top 15 mismatch pairs (city text -> joined municipality):")112    for row in mismatches.head(15).iter_rows(named=True):113        note(f"- '{row['city']}' -> '{row['municipality']}' ({row['region']}): {row['n']:,}")114115    report_path = REPORTS_DIR / "geography_report.md"116    header = (117        "<!--\n"118        "=============================================================================\n"119        "QWHPI — Quebec Weekly Housing Price Index\n"120        "Author  : Simon-Pierre Boucher\n"121        "Contact : contact@spboucher.ai\n"122        "File    : outputs/reports/geography_report.md\n"123        "Purpose : Step 2 geography join + validation report (from 02_geography.py)\n"124        "=============================================================================\n"125        "-->\n\n"126    )127    report_path.write_text(header + "\n".join(LINES) + "\n", encoding="utf-8")128    print(f"\nReport written to {report_path}")129130131if __name__ == "__main__":132    main()133