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.4 KB · 106 lines python
Raw Blame History
1# Author: Simon-Pierre Boucher — contact@spboucher.ai2"""Design matrices and the M1–M5 hedonic specification ladder.34The semi-logarithmic hedonic equation regresses ln(price) on structural5attributes, dwelling-type/ownership dummies and location fixed effects.6FSA fixed effects are absorbed with ``linearmodels.AbsorbingLS`` (numerically7identical to full-dummy OLS); standard errors are clustered by FSA.8"""9import numpy as np10import pandas as pd11import statsmodels.api as sm12from linearmodels.iv.absorbing import AbsorbingLS1314from .config import STRUCT151617def design(data: pd.DataFrame, extra: pd.DataFrame | None = None) -> pd.DataFrame:18    """Structural regressors + dwelling-type, ownership and category dummies."""19    blocks = [data[STRUCT].astype(float)]20    if extra is not None:21        blocks.append(extra)22    blocks += [pd.get_dummies(data[col], prefix=pfx, drop_first=True).astype(float)23               for col, pfx in (("btype_c", "bt"), ("own_c", "ow"), ("cat", "cat"))]24    return sm.add_constant(pd.concat(blocks, axis=1))252627def fit_absorbing(data: pd.DataFrame, extra: pd.DataFrame | None = None):28    """FSA-fixed-effects hedonic model with FSA-clustered standard errors.2930    Returns the fitted AbsorbingLS results and the design column order.31    """32    X = design(data, extra=extra)33    res = AbsorbingLS(data["ln_price"], X,34                      absorb=data[["fsa_c"]].astype("category"),35                      drop_absorbed=True).fit(cov_type="clustered",36                                              clusters=data[["fsa_c"]])37    return res, X.columns.tolist()383940def coef_table(res, columns=None) -> pd.DataFrame:41    """Coefficient / SE / p-value table indexed by regressor name.4243    Works for both statsmodels (``bse``) and linearmodels (``std_errors``);44    uses the estimator's own parameter index, which may exclude regressors45    dropped as collinear with the absorbed effects.46    """47    se = res.std_errors if hasattr(res, "std_errors") else res.bse48    index = res.params.index if hasattr(res.params, "index") else columns49    return pd.DataFrame({50        "coef": pd.Series(np.asarray(res.params).ravel(), index=index),51        "se": pd.Series(np.asarray(se).ravel(), index=index),52        "p": pd.Series(np.asarray(res.pvalues).ravel(), index=index),53    })545556def fsa_fixed_effects(data: pd.DataFrame, res, columns=None) -> pd.Series:57    """Recover the absorbed FSA effects as within-FSA mean residuals of X'b."""58    beta = pd.Series(np.asarray(res.params).ravel(), index=res.params.index)59    xb = design(data).reindex(columns=beta.index, fill_value=0.0).values @ beta.values60    return (data["ln_price"] - xb).groupby(data["fsa_c"]).mean()616263def predict_with_fe(data: pd.DataFrame, res, columns, fe: pd.Series) -> np.ndarray:64    """Linear prediction X'b + absorbed FSA effect."""65    beta = pd.Series(np.asarray(res.params).ravel(), index=res.params.index)66    xb = design(data).reindex(columns=beta.index, fill_value=0.0).values @ beta.values67    return xb + data["fsa_c"].map(fe).values686970def specification_ladder(sample: pd.DataFrame) -> dict:71    """Estimate the M1–M5 ladder; returns {name: (results, columns, data)}.7273    M1–M3 use the house subsample (structural; +type/ownership; +province);74    M4 is houses with FSA fixed effects; M5 is the grand model over all75    residential dwellings with FSA fixed effects.76    """77    houses = sample[sample["cat"] == "house"]78    out = {}7980    X1 = sm.add_constant(houses[STRUCT].astype(float))81    out["M1"] = (sm.OLS(houses["ln_price"], X1)82                 .fit(cov_type="cluster", cov_kwds={"groups": houses["fsa_c"]}),83                 X1.columns.tolist(), houses)8485    X2 = design(houses)86    out["M2"] = (sm.OLS(houses["ln_price"], X2)87                 .fit(cov_type="cluster", cov_kwds={"groups": houses["fsa_c"]}),88                 X2.columns.tolist(), houses)8990    X3 = X2.join(pd.get_dummies(houses["prov"], prefix="pv", drop_first=True).astype(float))91    out["M3"] = (sm.OLS(houses["ln_price"], X3)92                 .fit(cov_type="cluster", cov_kwds={"groups": houses["fsa_c"]}),93                 X3.columns.tolist(), houses)9495    res4, cols4 = fit_absorbing(houses)96    out["M4"] = (res4, cols4, houses)9798    res5, cols5 = fit_absorbing(sample)99    out["M5"] = (res5, cols5, sample)100    return out101102103def duan_smearing(residuals: np.ndarray) -> float:104    """Duan (1983) smearing factor for retransformation from logs."""105    return float(np.mean(np.exp(residuals)))106