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%
12.2 KB · 276 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/09_monthly.py7# Purpose : v2.1 — monthly hybrid indexes: RTD province anchor, DIRECT robust8#           local time-dummy for liquid cells, hierarchical shrinkage for9#           thin cells only.10# =============================================================================11"""Monthly index build (v2.1 headline).1213Architecture (arbitrated against repeat sales, local time dummies and14stratified matched-cell medians — see methodology_v2_research.md):1516- Province × type ....... rolling-time-dummy, Huber IRLS, mean splice.17- Liquid cell (median ≥ LIQUID_MIN tx/month) ... DIRECT local robust18  time-dummy (own β, own FSA FE), then a light state-space smoother19  (obs var σ²/nₘ) for the real-time variant. Deviation-from-pooled-surface20  methods compress strong local divergences (Québec City condo: +50–55%21  by every composition-free arbiter vs +42% by deviations), so liquid22  cells are estimated on their own data.23- Thin cell ............. deviation from its parent's published path via the24  heteroskedastic local-level Kalman model (unchanged machinery).25"""2627from __future__ import annotations2829import sys30import time31import unicodedata32from pathlib import Path3334sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))3536import numpy as np37import polars as pl3839from qwhpi.clean import CLEAN_PARQUET, research_sample40from qwhpi.config import CITY_TARGETS, INTERIM_DIR, PROPERTY_TYPES, TABLES_DIR, ensure_dirs41from qwhpi.features import build_features42from qwhpi.hierarchy import CellDeviation, base_constant, cell_deviation, compose_cell_table43from qwhpi.rtd import fullpool_residualize, local_time_dummy, run_rtd44from qwhpi.state_space import fit_local_level4546MONTHLY_PARQUET = INTERIM_DIR / "monthly_indexes.parquet"47FP_RESID = INTERIM_DIR / "rtd_residuals.parquet"48RTD_PATHS = INTERIM_DIR / "rtd_paths.parquet"4950LIQUID_MIN = 40  # median tx/month for direct local estimation515253def slug(name: str) -> str:54    s = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode()55    return s.lower().replace(" ", "-").replace("'", "")565758def city_slug(name: str) -> str:59    return "quebec-city" if name == "Québec" else slug(name)606162def compose_direct(grid, raw_path, n, sigma2, gid, gname, glevel, ptype):63    """Published table for a directly-estimated liquid cell."""64    fit = fit_local_level(raw_path, np.maximum(n, 1.0), sigma2)65    base = base_constant(grid, fit.filtered)66    log_raw = raw_path - base67    log_filt = fit.filtered - base68    log_smooth = fit.smoothed - base69    se = np.sqrt(np.maximum(fit.filtered_var, 0.0))70    return pl.DataFrame({71        "week": grid,72        "geography_id": gid, "geography_name": gname, "geography_level": glevel,73        "property_type": ptype,74        "index": np.exp(log_raw) * 100.0,75        "index_smoothed": np.exp(log_filt) * 100.0,76        "index_research": np.exp(log_smooth) * 100.0,77        "log_index": log_filt,78        "se_log": se,79        "lower_95": np.exp(log_filt - 1.96 * se) * 100.0,80        "upper_95": np.exp(log_filt + 1.96 * se) * 100.0,81        "transactions": n,82        "shrinkage_weight": 1.0 - fit.gain,  # temporal smoothing weight83        "research_var": np.maximum(fit.smoothed_var, 0.0),84    }), fit858687def combine_all(tables, weights, gid, gname, glevel):88    """Fixed-share composition of the three type indexes into 'all'."""89    wsum = sum(weights.values())90    w = {k: v / wsum for k, v in weights.items()}91    base = tables[next(iter(tables))].sort("week")92    grid = base["week"].to_list()9394    def wavg(col, log=True):95        acc = np.zeros(len(grid))96        for k, tbl in tables.items():97            vals = tbl.sort("week")[col].to_numpy()98            acc += w[k] * (np.log(vals / 100.0) if log else vals)99        return acc100101    log_raw, log_filt, log_res = wavg("index"), wavg("index_smoothed"), wavg("index_research")102    var = sum((w[k] ** 2) * (tbl.sort("week")["se_log"].to_numpy() ** 2)103              for k, tbl in tables.items())104    se = np.sqrt(var)105    n = sum(np.nan_to_num(tbl.sort("week")["transactions"].to_numpy())106            for tbl in tables.values())107    return pl.DataFrame({108        "week": grid,109        "geography_id": gid, "geography_name": gname, "geography_level": glevel,110        "property_type": "all",111        "index": np.exp(log_raw) * 100.0,112        "index_smoothed": np.exp(log_filt) * 100.0,113        "index_research": np.exp(log_res) * 100.0,114        "log_index": log_filt,115        "se_log": se,116        "lower_95": np.exp(log_filt - 1.96 * se) * 100.0,117        "upper_95": np.exp(log_filt + 1.96 * se) * 100.0,118        "transactions": n,119        "shrinkage_weight": wavg("shrinkage_weight", log=False),120        "research_var": var,121    })122123124def main() -> None:125    ensure_dirs()126    feat = build_features(research_sample(pl.read_parquet(CLEAN_PARQUET)))127128    print("Stage 1: province RTD (13-month windows, Huber IRLS) ...")129    t0 = time.time()130    rtd = run_rtd(feat, verbose=False)131    print(f"  done in {time.time() - t0:.0f}s")132    rtd.drift.write_csv(TABLES_DIR / "rtd_coefficient_drift.csv")133134    print("Stage 1b: full-pool robust residualization (thin-cell hierarchy) ...")135    fp_resid = fullpool_residualize(feat)136    fp_resid.write_parquet(FP_RESID)137    sigma2 = {t: float(fp_resid.filter(pl.col("propertyType") == t)["resid"].var())138              for t in PROPERTY_TYPES}139140    grid = rtd.months141    T = len(grid)142    pl.concat([143        pl.DataFrame({"month": grid, "property_type": t,144                      "mean_splice": rtd.paths[t],145                      "movement_splice": rtd.paths_movement[t],146                      "window_splice": rtd.paths_window[t]})147        for t in PROPERTY_TYPES148    ]).write_parquet(RTD_PATHS)149150    resid = fp_resid.rename({"month": "week_str"})151    region_names = dict(feat.select("region_code", "region").unique().iter_rows())152    frames: list[pl.DataFrame] = []153    estimation_log: list[dict] = []154155    # Parent references for thin cells:156    region_resid_dev: dict[tuple[str, str], CellDeviation] = {}157    region_anchor: dict[tuple[str, str], np.ndarray] = {}  # published filtered log path158159    for ptype in PROPERTY_TYPES:160        path = rtd.paths[ptype]161        r_t = resid.filter(pl.col("propertyType") == ptype)162        f_t = feat.filter(pl.col("propertyType") == ptype)163        counts = {r["week_str"]: r["n"] for r in164                  r_t.group_by("week_str").agg(pl.len().alias("n")).iter_rows(named=True)}165        n_prov = np.array([counts.get(m, 0) for m in grid], dtype=float)166        path_var = np.where(n_prov > 0, sigma2[ptype] / np.maximum(n_prov, 1), np.nan)167        path_var = np.nan_to_num(path_var, nan=float(np.nanmax(path_var)))168169        prov = compose_cell_table(170            grid=grid, province_path=path, deviations=[],171            geography_id="quebec", geography_name="Québec (province)",172            geography_level="province", property_type=ptype, path_var=path_var,173        ).with_columns(pl.Series("transactions", n_prov),174                       pl.Series("shrinkage_weight", np.zeros(T)))175        frames.append(prov)176        estimation_log.append({"cell": f"quebec/{ptype}", "method": "rtd_mean_splice"})177178        def monthly_counts(sub: pl.DataFrame) -> np.ndarray:179            c = dict(sub.group_by("month").agg(pl.len().alias("n")).iter_rows())180            return np.array([c.get(m, 0) for m in grid], dtype=float)181182        # ---------------- regions ---------------- #183        for rcode in sorted(region_names):184            cell_r = r_t.filter(pl.col("region_code") == rcode)185            cell_f = f_t.filter(pl.col("region_code") == rcode)186            n_m = monthly_counts(cell_f)187            med = float(np.median(n_m))188            rdev = cell_deviation(cell_r.rename({"resid": "dev"})189                                  .select("week_str", "dev"), grid, sigma2[ptype])190            region_resid_dev[(rcode, ptype)] = rdev191            gid = f"region-{rcode}"192193            direct = local_time_dummy(cell_f, grid) if med >= LIQUID_MIN else None194            if direct is not None:195                raw_path, n_cell, s2 = direct196                tbl, fit = compose_direct(grid, raw_path, n_cell, s2,197                                          gid, region_names[rcode], "region", ptype)198                frames.append(tbl)199                region_anchor[(rcode, ptype)] = raw_path * 0 + fit.filtered  # filtered log path200                estimation_log.append({"cell": f"{gid}/{ptype}", "method": "direct_local_td",201                                       "median_tx": med})202            else:203                tbl = compose_cell_table(204                    grid=grid, province_path=path, deviations=[rdev],205                    geography_id=gid, geography_name=region_names[rcode],206                    geography_level="region", property_type=ptype, path_var=path_var)207                frames.append(tbl)208                region_anchor[(rcode, ptype)] = path + rdev.fit.filtered209                estimation_log.append({"cell": f"{gid}/{ptype}", "method": "hierarchical",210                                       "median_tx": med})211212        # ---------------- cities ---------------- #213        for city in CITY_TARGETS:214            cell_f = f_t.filter(pl.col("municipality") == city)215            if cell_f.height == 0:216                continue217            rcode = cell_f["region_code"][0]218            gid = city_slug(city)219            n_m = monthly_counts(cell_f)220            med = float(np.median(n_m))221222            direct = local_time_dummy(cell_f, grid) if med >= LIQUID_MIN else None223            if direct is not None:224                raw_path, n_cell, s2 = direct225                tbl, _ = compose_direct(grid, raw_path, n_cell, s2,226                                        gid, city, "municipality", ptype)227                frames.append(tbl)228                estimation_log.append({"cell": f"{gid}/{ptype}", "method": "direct_local_td",229                                       "median_tx": med})230            else:231                # deviation of the city vs its REGION's residual path, anchored232                # on the region's published (filtered) log path233                rdev = region_resid_dev[(rcode, ptype)]234                rpath = {m: v for m, v in zip(grid, rdev.fit.smoothed)}235                cell_r = r_t.filter(pl.col("municipality") == city).with_columns(236                    (pl.col("resid") - pl.col("week_str").replace_strict(rpath, default=0.0))237                    .alias("dev"))238                mdev = cell_deviation(cell_r.select("week_str", "dev"), grid, sigma2[ptype])239                tbl = compose_cell_table(240                    grid=grid, province_path=region_anchor[(rcode, ptype)],241                    deviations=[mdev],242                    geography_id=gid, geography_name=city,243                    geography_level="municipality", property_type=ptype,244                    path_var=path_var)245                frames.append(tbl)246                estimation_log.append({"cell": f"{gid}/{ptype}", "method": "hierarchical",247                                       "median_tx": med})248249    out = pl.concat(frames)250251    all_frames = []252    for gid, gname, glevel in out.select(253            "geography_id", "geography_name", "geography_level").unique().iter_rows():254        tables = {t: out.filter((pl.col("geography_id") == gid)255                                & (pl.col("property_type") == t))256                  for t in PROPERTY_TYPES}257        tables = {t: tbl for t, tbl in tables.items() if tbl.height}258        weights = {t: max(float(np.nansum(tbl["transactions"].to_numpy())), 1.0)259                   for t, tbl in tables.items()}260        all_frames.append(combine_all(tables, weights, gid, gname, glevel))261262    out = pl.concat([out, *all_frames]).rename({"week": "period"}).sort(263        ["geography_level", "geography_id", "property_type", "period"])264    out.write_parquet(MONTHLY_PARQUET)265266    log_df = pl.DataFrame(estimation_log)267    log_df.write_csv(TABLES_DIR / "estimation_methods.csv")268    n_direct = log_df.filter(pl.col("method") == "direct_local_td").height269    n_hier = log_df.filter(pl.col("method") == "hierarchical").height270    print(f"\nCells: {n_direct} direct, {n_hier} hierarchical, 3 RTD province")271    print(f"Saved {out.height:,} observations to {MONTHLY_PARQUET}")272273274if __name__ == "__main__":275    main()276