SPB Git

spb/wp5_uqo Public

UQO Working Paper No. 5 — Airbnb, residential rents and housing market pressure.

TeX 53.4% Python 46.5%
11.3 KB · 308 lines python
Raw Blame History
1# Author: Simon-Pierre Boucher — contact@spboucher.ai2"""306_hedonic_models.py4--------------------5Hedonic regression models for the Airbnb-rent analysis.67Model 1 (1a-1e): Baseline hedonic rent model (OLS, HC1)8Model 2 (2a-2c): Hedonic Airbnb pricing model9Model 3:         City-level interaction (forward + reverse)1011Outputs:12    results/tables/hedonic_rent_baseline.tex13    results/tables/hedonic_airbnb_pricing.tex14    results/tables/city_level_interaction.tex15"""1617import sys18from pathlib import Path1920sys.path.insert(0, str(Path(__file__).resolve().parent.parent))2122import numpy as np23import pandas as pd24import statsmodels.api as sm2526from src.config import AIRBNB_CLEAN, MERGED_ANALYSIS, TABLE_DIR, require27from src.latex_tables import significance_star, results_to_latex2829PROCESSED_HINT = "Run scripts/04_merge_data.py first."303132def run_ols(df: pd.DataFrame, y_col: str, x_cols: list[str], label: str):33    """Run OLS with HC1 robust SE, dropping NaN rows for relevant cols."""34    cols = [y_col] + x_cols35    sub = df[cols].dropna()36    Y = sub[y_col]37    X = sm.add_constant(sub[x_cols])38    model = sm.OLS(Y, X).fit(cov_type="HC1")39    print(f"\n  [{label}]  N={int(model.nobs):,}  R2={model.rsquared:.4f}  "40          f"Adj-R2={model.rsquared_adj:.4f}")41    return model424344# ═════════════════════════════════════════════════════════════════════════════45# MODEL 1: Baseline Hedonic Rent Model (1a – 1e)46# ═════════════════════════════════════════════════════════════════════════════4748def model1_hedonic_rent(rent: pd.DataFrame) -> None:49    print("\n" + "-" * 72)50    print("MODEL 1: Baseline Hedonic Rent Model")51    print("-" * 72)5253    # --- prepare variables -------------------------------------------------54    rent_m = rent.copy()5556    # Building-type dummies57    if "building_type" in rent_m.columns:58        bt_dummies = pd.get_dummies(rent_m["building_type"], prefix="bt",59                                    drop_first=True, dtype=float)60        rent_m = pd.concat([rent_m, bt_dummies], axis=1)61        bt_cols = list(bt_dummies.columns)62    else:63        bt_cols = []6465    # City dummies (fixed effects)66    if "city" in rent_m.columns:67        city_dummies = pd.get_dummies(rent_m["city"], prefix="city",68                                      drop_first=True, dtype=float)69        rent_m = pd.concat([rent_m, city_dummies], axis=1)70        city_fe_cols = list(city_dummies.columns)71    else:72        city_fe_cols = []7374    controls = ["bedrooms", "bathrooms"] + bt_cols7576    # 1a: No controls, no FE77    res_1a = run_ols(rent_m, "log_rent", ["airbnb_count_500m"], "1a")7879    # 1b: With controls, no FE80    res_1b = run_ols(rent_m, "log_rent", ["airbnb_count_500m"] + controls, "1b")8182    # 1c: With controls + city FE83    res_1c = run_ols(84        rent_m, "log_rent", ["airbnb_count_500m"] + controls + city_fe_cols, "1c"85    )8687    # 1d: airbnb_density_500m instead88    res_1d = run_ols(89        rent_m, "log_rent", ["airbnb_density_500m"] + controls + city_fe_cols, "1d"90    )9192    # 1e: share_entire_home_500m instead93    res_1e = run_ols(94        rent_m, "log_rent", ["share_entire_home_500m"] + controls + city_fe_cols, "1e"95    )9697    # -- Key display variables (not all city/building dummies) --------------98    display_rent = (99        ["const", "airbnb_count_500m", "airbnb_density_500m",100         "share_entire_home_500m", "bedrooms", "bathrooms"]101        + bt_cols102    )103    all_res = [res_1a, res_1b, res_1c, res_1d, res_1e]104105    results_to_latex(106        all_res,107        ["(1a)", "(1b)", "(1c)", "(1d)", "(1e)"],108        dep_var="log\\_rent",109        display_vars=[v for v in display_rent110                      if any(v in r.params.index for r in all_res)],111        out_path=TABLE_DIR / "hedonic_rent_baseline.tex",112        note="Models (1c)-(1e) include city fixed effects (not shown).",113    )114115116# ═════════════════════════════════════════════════════════════════════════════117# MODEL 2: Hedonic Airbnb Pricing Model (2a – 2c)118# ═════════════════════════════════════════════════════════════════════════════119120def model2_airbnb_pricing(rent: pd.DataFrame, airbnb: pd.DataFrame) -> None:121    print("\n" + "-" * 72)122    print("MODEL 2: Hedonic Airbnb Pricing Model")123    print("-" * 72)124125    # Compute mean rent per city from rent data126    city_col_rent = "city"127    # Determine city column name in airbnb data128    city_col_ab = "city_clean" if "city_clean" in airbnb.columns else "city"129130    mean_rent_city = (131        rent.groupby(city_col_rent)["log_rent"]132        .mean()133        .rename("mean_rent_city")134        .reset_index()135        .rename(columns={city_col_rent: city_col_ab})136    )137    print(f"\n  Mean rent computed for {len(mean_rent_city)} cities")138139    ab = airbnb.merge(mean_rent_city, on=city_col_ab, how="inner")140    print(f"  Airbnb rows after merge: {len(ab):,}")141142    # Ensure log_price exists143    if "log_price" not in ab.columns and "price_numeric" in ab.columns:144        ab["log_price"] = np.log(ab["price_numeric"].clip(lower=1))145146    # City FE for Airbnb147    ab_city_dum = pd.get_dummies(ab[city_col_ab], prefix="acity",148                                 drop_first=True, dtype=float)149    ab = pd.concat([ab, ab_city_dum], axis=1)150    ab_city_fe = list(ab_city_dum.columns)151152    ab_controls = [153        c for c in ["bedrooms", "bathrooms", "guests_count",154                    "amenities_count", "is_superhost", "is_entire_home"]155        if c in ab.columns156    ]157158    # Convert boolean controls to float159    for c in ab_controls:160        if ab[c].dtype == bool:161            ab[c] = ab[c].astype(float)162163    # 2a: No controls164    res_2a = run_ols(ab, "log_price", ["mean_rent_city"], "2a")165166    # 2b: With controls167    res_2b = run_ols(ab, "log_price", ["mean_rent_city"] + ab_controls, "2b")168169    # 2c: With controls + city FE170    res_2c = run_ols(ab, "log_price",171                     ["mean_rent_city"] + ab_controls + ab_city_fe, "2c")172173    display_ab = ["const", "mean_rent_city"] + ab_controls174    all_res = [res_2a, res_2b, res_2c]175176    results_to_latex(177        all_res,178        ["(2a)", "(2b)", "(2c)"],179        dep_var="log\\_price",180        display_vars=[v for v in display_ab181                      if any(v in r.params.index for r in all_res)],182        out_path=TABLE_DIR / "hedonic_airbnb_pricing.tex",183        note="Model (2c) includes city fixed effects (not shown).",184    )185186187# ═════════════════════════════════════════════════════════════════════════════188# MODEL 3: City-level Interaction Model189# ═════════════════════════════════════════════════════════════════════════════190191def model3_city_interaction(rent: pd.DataFrame) -> None:192    print("\n" + "-" * 72)193    print("MODEL 3: City-level Interaction")194    print("-" * 72)195196    # Aggregate rent data to city level197    city_agg = (198        rent.groupby("city")199        .agg(200            mean_log_rent=("log_rent", "mean"),201            mean_bedrooms=("bedrooms", "mean"),202            mean_bathrooms=("bathrooms", "mean"),203            n_rent_listings=("log_rent", "count"),204        )205        .reset_index()206    )207208    # Get airbnb_count_city from rent data (should be constant within a city)209    airbnb_city_vars = [c for c in rent.columns210                        if c.startswith("airbnb_") and c.endswith("_city")]211    if airbnb_city_vars:212        city_airbnb = rent.groupby("city")[airbnb_city_vars].first().reset_index()213        city_agg = city_agg.merge(city_airbnb, on="city", how="left")214215    print(f"  City-level rows: {len(city_agg)}")216    print(f"  Columns: {list(city_agg.columns)}")217218    # Determine which airbnb count variable is available219    ab_count_col = "airbnb_count_city" if "airbnb_count_city" in city_agg.columns else None220    city_controls = ["mean_bedrooms", "mean_bathrooms"]221222    if ab_count_col is None:223        print("  WARNING: airbnb_count_city not found — skipping Model 3.")224        return225226    # 3-forward: mean_log_rent ~ airbnb_count_city + controls227    res_3fwd = run_ols(228        city_agg, "mean_log_rent", [ab_count_col] + city_controls, "3-fwd"229    )230    # 3-reverse: airbnb_count_city ~ mean_log_rent + controls231    res_3rev = run_ols(232        city_agg, ab_count_col, ["mean_log_rent"] + city_controls, "3-rev"233    )234235    # LaTeX table (fragment: the paper supplies the table environment)236    lines: list[str] = []237    lines.append(r"\begin{tabular}{lcc}")238    lines.append(r"\toprule")239    lines.append(r" & \textbf{(3-fwd)} & \textbf{(3-rev)} \\")240    lines.append(241        r"Dep.\ var: & \textit{mean\_log\_rent} & \textit{airbnb\_count\_city} \\"242    )243    lines.append(r"\midrule")244245    # Show all variables for both models246    all_vars_3 = list(dict.fromkeys(247        list(res_3fwd.params.index) + list(res_3rev.params.index)248    ))249    for var in all_vars_3:250        cells_coef = []251        cells_se = []252        for res in [res_3fwd, res_3rev]:253            if var in res.params.index:254                b = res.params[var]255                se = res.bse[var]256                p = res.pvalues[var]257                cells_coef.append(f"{b:.4f}{significance_star(p)}")258                cells_se.append(f"({se:.4f})")259            else:260                cells_coef.append("")261                cells_se.append("")262        vn = var.replace("_", r"\_")263        lines.append(f"{vn} & {cells_coef[0]} & {cells_coef[1]} " + r"\\")264        lines.append(f" & {cells_se[0]} & {cells_se[1]} " + r"\\[4pt]")265266    lines.append(r"\midrule")267    lines.append(268        f"Observations & {int(res_3fwd.nobs)} & {int(res_3rev.nobs)} " + r"\\"269    )270    lines.append(271        f"R$^2$ & {res_3fwd.rsquared:.4f} & {res_3rev.rsquared:.4f} " + r"\\"272    )273    lines.append(r"\bottomrule")274    lines.append(r"\end{tabular}")275    lines.append(276        r"\parbox{\textwidth}{\footnotesize Robust (HC1) standard errors in "277        r"parentheses. $^{***}p<0.01$; $^{**}p<0.05$; $^{*}p<0.10$.}"278    )279280    tex3 = "\n".join(lines) + "\n"281    out3 = TABLE_DIR / "city_level_interaction.tex"282    out3.write_text(tex3, encoding="utf-8")283    print(f"  -> saved {out3}")284285286def main() -> None:287    print("=" * 72)288    print("06  HEDONIC REGRESSION MODELS")289    print("=" * 72)290291    rent = pd.read_parquet(require(MERGED_ANALYSIS, PROCESSED_HINT))292    airbnb = pd.read_parquet(require(AIRBNB_CLEAN, PROCESSED_HINT))293294    print(f"\nRent data: {rent.shape[0]:,} rows, {rent.shape[1]} cols")295    print(f"Airbnb data: {airbnb.shape[0]:,} rows, {airbnb.shape[1]} cols")296297    model1_hedonic_rent(rent)298    model2_airbnb_pricing(rent, airbnb)299    model3_city_interaction(rent)300301    print("\n" + "=" * 72)302    print("06  DONE")303    print("=" * 72)304305306if __name__ == "__main__":307    main()308