SPB Git

spb/wp2_uqo

Public

UQO Working Paper No. 2 — Decoding Real Estate Descriptions: text-based hedonic analysis of housing listings.

4.3 KB · 128 lines python
1# Author: Simon-Pierre Boucher — contact@spboucher.ai2#3"""Helpers for the 2026-10-17 revision extensions (scripts/07_revision_extensions.py).45Language detection, unit-corrected lot size, floor area, forward sortation6areas (FSA), fixed-effects / clustered OLS and out-of-sample metrics.7"""89import json10import re11import sqlite31213import numpy as np14import pandas as pd15import statsmodels.api as sm1617FR_STOPWORDS = set(18    "le la les des une un et est avec dans pour sur au aux du de à cette ce cet "19    "ses sa son vous nous très plus rez chaussée cuisine chambre salle bain terrain "20    "étage sous-sol près".split()21)22EN_STOPWORDS = set(23    "the and with for this that is are has have from your you in on at of to a an "24    "kitchen bedroom bathroom basement floor lot home house living room large new".split()25)2627SQM_PER_UNIT = {"m2": 1.0, "sqft": 0.09290304, "ac": 4046.8564, "hec": 10000.0}282930def detect_language(text):31    """Return 'fr', 'en' or 'mixed' from stop-word counts (ratio rule 1.5)."""32    words = re.findall(r"[a-zàâçéèêëîïôûùüÿœ'-]+", text.lower())33    n_fr = sum(w in FR_STOPWORDS for w in words)34    n_en = sum(w in EN_STOPWORDS for w in words)35    if n_fr > 1.5 * n_en:36        return "fr"37    if n_en > 1.5 * n_fr:38        return "en"39    return "mixed"404142def _area_sqm(size_str):43    """Convert a Realtor.ca size string ('98900 m2', '5000 sqft', '2 ac') to m²."""44    if not size_str or not isinstance(size_str, str):45        return np.nan46    m = re.match(r"^\s*([\d.,]+)\s*([a-zA-Z0-9]*)", size_str)47    if not m:48        return np.nan49    try:50        value = float(m.group(1).replace(",", ""))51    except ValueError:52        return np.nan53    unit = m.group(2).lower()54    if unit not in SQM_PER_UNIT or value <= 0:55        return np.nan56    return value * SQM_PER_UNIT[unit]575859def load_revision_fields(db_path, ids):60    """Postal code / FSA, municipality, lot area (m²), floor area (m²) for the sample."""61    conn = sqlite3.connect(db_path)62    rows = conn.execute(63        "SELECT id, data FROM properties WHERE category = 'house'"64    ).fetchall()65    conn.close()66    wanted = set(ids)67    recs = {}68    for pid, data_json in rows:69        if pid not in wanted:70            continue71        try:72            d = json.loads(data_json)73        except (json.JSONDecodeError, TypeError):74            continue75        postal = d.get("PostalCode") or ""76        addr = d.get("Property", {}).get("Address", {}).get("AddressText", "")77        city = addr.split("|")[-1].split(",")[0].strip() if "|" in addr else ""78        recs[pid] = {79            "postal_code": postal,80            "fsa": postal[:3].upper(),81            "municipality": city,82            "lot_sqm": _area_sqm(d.get("Land", {}).get("SizeTotal", "")),83            "floor_sqm": _area_sqm(d.get("Building", {}).get("SizeInterior", "")),84            "scraped_at": d.get("scrapedAt"),85        }86    out = pd.DataFrame([recs.get(i, {}) for i in ids])87    out.index = pd.Index(ids)88    return out899091def ols(y, X, cov_type="HC3", groups=None):92    """OLS with HC3 or cluster-robust covariance."""93    if cov_type == "cluster":94        return sm.OLS(y, X).fit(cov_type="cluster", cov_kwds={"groups": groups})95    return sm.OLS(y, X).fit(cov_type=cov_type)969798def zscore(df, columns):99    X = (df[columns] - df[columns].mean()) / df[columns].std(ddof=0)100    return X101102103def within(df, columns, group):104    """Demean columns within groups (fixed-effects transformation)."""105    return df[columns] - df.groupby(group)[columns].transform("mean")106107108def block_f_test(model, variables):109    """Wald F-test that all coefficients in `variables` are zero (robust cov)."""110    names = list(model.params.index)111    R = np.zeros((len(variables), len(names)))112    for i, v in enumerate(variables):113        R[i, names.index(v)] = 1.0114    res = model.f_test(R)115    return float(np.squeeze(res.fvalue)), float(res.pvalue), int(res.df_num)116117118def oos_metrics(y_true_log, y_pred_log):119    """Out-of-sample R² (log scale), RMSE (log), MdAPE (level)."""120    resid = y_true_log - y_pred_log121    ss_res = float(np.sum(resid ** 2))122    ss_tot = float(np.sum((y_true_log - y_true_log.mean()) ** 2))123    price = np.exp(y_true_log)124    mdape = float(np.median(np.abs(np.exp(y_pred_log) - price) / price)) * 100125    return {"r2_oos": 1 - ss_res / ss_tot,126            "rmse_log": float(np.sqrt(np.mean(resid ** 2))),127            "mdape_pct": mdape}128
Files · main
Menu

Theme