spb/wp5_uqo Public
UQO Working Paper No. 5 — Airbnb, residential rents and housing market pressure.
TeX 53.4%
Python 46.5%
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