SPB Git

spb/wp9_uqo Public

UQO Working Paper No. 9 — A grand hedonic model of the Canadian housing market: decomposing structure and location value.

TeX 60.1% Python 39.8%
4.7 KB · 115 lines python
Raw Blame History
1# Author: Simon-Pierre Boucher — contact@spboucher.ai2"""Construction of the estimation sample from the raw DuckDB snapshot.34Steps (documented in the paper's data section):561. read the raw ``listings`` table (172,019 unique for-sale listings);72. parse the semi-structured fields (bedrooms, areas, lot, counts);83. consolidate dwelling type and ownership form; derive the FSA from the9   postal code;104. keep residential listings with a strictly positive price and non-missing11   core structural fields (living area, bedrooms, bathrooms) and a valid FSA;125. trim the extreme 1% tails of price and living area;136. pool FSAs with fewer than 25 listings into a province-level residual14   category.15"""16import duckdb17import numpy as np18import pandas as pd1920from . import parsing21from .config import (ANALYSIS_PARQUET, FSA_MIN_LISTINGS, LOT_TAIL_Q, RAW_DB,22                     TRIM_HI, TRIM_LO, ensure_dirs)2324_RAW_QUERY = """25select "_category"                        as cat,26       "_province"                        as prov,27       "building.type"                    as btype_raw,28       "ownership.type"                   as own_raw,29       "building.bedrooms"                as bed_raw,30       "building.bathroom_total"          as bath_raw,31       "building.half_bath_total"         as half_raw,32       "building.size_interior"           as size_interior,33       "building.floor_area_measurements" as floor_area,34       "building.stories_total"           as stories_raw,35       "parking.spaces_total"             as parking_raw,36       "land.size_total"                  as lot_raw,37       "location.postal_code"             as postal_code,38       price_cad, lat, lon39from listings40"""4142_FSA_PATTERN = r"^[A-Z]\d[A-Z]$"434445def load_raw() -> pd.DataFrame:46    """Read the raw listings table from the DuckDB snapshot."""47    with duckdb.connect(str(RAW_DB), read_only=True) as con:48        return con.execute(_RAW_QUERY).df()495051def parse_variables(raw: pd.DataFrame) -> pd.DataFrame:52    """Parse raw text fields into numeric/categorical analysis variables."""53    d = raw.copy()54    d["bedrooms"] = d["bed_raw"].map(parsing.parse_bedrooms)55    d["bathrooms"] = d["bath_raw"].map(parsing.parse_count)56    d["half_baths"] = d["half_raw"].map(parsing.parse_count).fillna(0.0)57    d["parking_n"] = d["parking_raw"].map(parsing.parse_count).fillna(0.0)58    d["stories_n"] = d["stories_raw"].map(parsing.parse_count).fillna(0.0)59    d["living_m2"] = (d["floor_area"].map(parsing.parse_floor_area)60                      .fillna(d["size_interior"].map(parsing.parse_size_interior)))6162    lot = d["lot_raw"].map(parsing.parse_lot)63    # Very large parsed lots (top 1% of positive values, mostly multi-acre64    # rural acreage strings) are treated as "no usable lot information".65    cap = lot[lot > 0].quantile(LOT_TAIL_Q)66    lot = lot.where(lot <= cap, np.nan)67    d["lot_m2_f"] = lot.fillna(0.0)68    d["has_lot"] = (d["lot_m2_f"] > 0).astype(float)6970    d["btype_c"] = d["btype_raw"].map(parsing.consolidate_building_type)71    d["own_c"] = d["own_raw"].map(parsing.consolidate_ownership)7273    fsa = d["postal_code"].astype("string").str.upper().str[:3]74    d["fsa"] = fsa.where(fsa.str.match(_FSA_PATTERN, na=False))75    return d767778def build_sample(d: pd.DataFrame) -> pd.DataFrame:79    """Apply the sample restrictions and derive the model variables."""80    core = ((d["price_cad"] > 0) & d["living_m2"].notna()81            & d["bedrooms"].notna() & d["bathrooms"].notna() & d["fsa"].notna())82    s = d[core].copy()8384    lo_p, hi_p = s["price_cad"].quantile([TRIM_LO, TRIM_HI])85    lo_a, hi_a = s["living_m2"].quantile([TRIM_LO, TRIM_HI])86    s = s[(s["price_cad"].between(lo_p, hi_p)) & (s["living_m2"].between(lo_a, hi_a))].copy()8788    s["ln_price"] = np.log(s["price_cad"])89    s["ln_living"] = np.log(s["living_m2"])90    s["ln_lot"] = np.log1p(s["lot_m2_f"])91    s["ppm2"] = s["price_cad"] / s["living_m2"]9293    counts = s.groupby("fsa")["fsa"].transform("size")94    s["fsa_c"] = np.where(counts >= FSA_MIN_LISTINGS, s["fsa"], s["prov"] + "_other")9596    keep = ["cat", "prov", "btype_c", "own_c", "fsa", "fsa_c",97            "price_cad", "ln_price", "ppm2",98            "living_m2", "ln_living", "bedrooms", "bathrooms", "half_baths",99            "parking_n", "stories_n", "lot_m2_f", "has_lot", "ln_lot",100            "lat", "lon"]101    return s[keep].reset_index(drop=True)102103104def build_and_save() -> pd.DataFrame:105    """Full raw-to-parquet pipeline; returns the estimation sample."""106    ensure_dirs()107    sample = build_sample(parse_variables(load_raw()))108    sample.to_parquet(ANALYSIS_PARQUET, index=False)109    return sample110111112def load_sample() -> pd.DataFrame:113    """Load the processed estimation sample (build it first with script 01)."""114    return pd.read_parquet(ANALYSIS_PARQUET)115