Python 64.6%
TeX 23.3%
HTML 11.6%
Makefile 0.5%
1"""2Airbnb Hedonic Pricing Lab3==========================4Interactive Streamlit dashboard for hedonic pricing analysis of Quebec Airbnb listings.5"""67import streamlit as st8import pandas as pd9import numpy as np10import plotly.express as px11import plotly.graph_objects as go12import matplotlib13matplotlib.use("Agg")14import matplotlib.pyplot as plt15import warnings16import os17import joblib1819warnings.filterwarnings("ignore")2021# ---------------------------------------------------------------------------22# Page config23# ---------------------------------------------------------------------------24st.set_page_config(25 page_title="Airbnb Hedonic Pricing Lab",26 page_icon="🏠",27 layout="wide",28 initial_sidebar_state="expanded",29)3031DATA_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "raw", "cleaned_dataset_sample.csv")32MODEL_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "results", "models")3334# ---------------------------------------------------------------------------35# Custom CSS36# ---------------------------------------------------------------------------37st.markdown(38 """39 <style>40 .block-container {padding-top: 1.5rem; padding-bottom: 1rem;}41 h1 {color: #FF5A5F;}42 .stTabs [data-baseweb="tab-list"] {gap: 8px;}43 .stTabs [data-baseweb="tab"] {44 padding: 8px 20px;45 border-radius: 6px 6px 0 0;46 font-weight: 600;47 }48 .metric-card {49 background: #f8f9fa; border-radius: 10px; padding: 16px;50 text-align: center; border: 1px solid #e9ecef;51 }52 </style>53 """,54 unsafe_allow_html=True,55)5657# ---------------------------------------------------------------------------58# Data loading59# ---------------------------------------------------------------------------60@st.cache_data61def load_data():62 df = pd.read_csv(DATA_PATH)63 # Ensure boolean columns are truly boolean64 for col in ["is_superhost", "is_guest_favorite", "pets_allowed"]:65 df[col] = df[col].astype(bool)66 # Log price (for regression)67 df["log_price"] = np.log1p(df["price_numeric"])68 return df697071raw_df = load_data()7273# ---------------------------------------------------------------------------74# Sidebar: global filters75# ---------------------------------------------------------------------------76st.sidebar.title("Airbnb Hedonic Pricing Lab")77st.sidebar.markdown("---")78st.sidebar.header("Global Filters")7980all_cities = sorted(raw_df["city"].dropna().unique())81top_cities = (82 raw_df["city"]83 .value_counts()84 .head(20)85 .index.tolist()86)8788selected_cities = st.sidebar.multiselect(89 "Cities",90 options=all_cities,91 default=top_cities[:5],92 help="Leave empty to include all cities.",93)9495all_property_types = sorted(raw_df["property_type"].dropna().unique())96selected_property_types = st.sidebar.multiselect(97 "Property Types",98 options=all_property_types,99 default=all_property_types,100)101102price_valid = raw_df["price_numeric"].dropna()103price_min_data = float(price_valid.min())104price_max_data = float(min(price_valid.max(), 10000)) # cap slider for UX105price_range = st.sidebar.slider(106 "Price Range ($)",107 min_value=0.0,108 max_value=price_max_data,109 value=(0.0, price_max_data),110 step=10.0,111)112113superhost_only = st.sidebar.checkbox("Superhost only", value=False)114115st.sidebar.markdown("---")116st.sidebar.caption(117 f"Dataset: {len(raw_df):,} listings across {raw_df['city'].nunique()} cities in Quebec."118)119120# ---------------------------------------------------------------------------121# Apply global filters122# ---------------------------------------------------------------------------123def apply_global_filters(df: pd.DataFrame) -> pd.DataFrame:124 mask = pd.Series(True, index=df.index)125 if selected_cities:126 mask &= df["city"].isin(selected_cities)127 if selected_property_types:128 mask &= df["property_type"].isin(selected_property_types)129 mask &= (df["price_numeric"] >= price_range[0]) | df["price_numeric"].isna()130 mask &= (df["price_numeric"] <= price_range[1]) | df["price_numeric"].isna()131 if superhost_only:132 mask &= df["is_superhost"]133 return df.loc[mask].copy()134135136df = apply_global_filters(raw_df)137138# ---------------------------------------------------------------------------139# Hedonic model helpers140# ---------------------------------------------------------------------------141FEATURE_COLS = [142 "bedrooms",143 "bathrooms",144 "guests_count",145 "amenities_count",146 "num_images",147 "rating",148 "is_superhost",149 "is_guest_favorite",150 "pets_allowed",151]152153NUMERIC_FEATURES = [154 "bedrooms",155 "bathrooms",156 "guests_count",157 "amenities_count",158 "num_images",159 "rating",160]161162BOOL_FEATURES = ["is_superhost", "is_guest_favorite", "pets_allowed"]163164165def _prepare_model_df(data: pd.DataFrame) -> pd.DataFrame:166 """Return a clean DataFrame ready for modelling (no NaN in target/features)."""167 mdf = data.dropna(subset=["price_numeric"]).copy()168 mdf = mdf[mdf["price_numeric"] > 0].copy()169 mdf["log_price"] = np.log(mdf["price_numeric"])170 for col in BOOL_FEATURES:171 mdf[col] = mdf[col].astype(int)172 # City dummies (top cities)173 city_counts = mdf["city"].value_counts()174 top = city_counts[city_counts >= 20].index.tolist()175 for c in top:176 mdf[f"city_{c}"] = (mdf["city"] == c).astype(int)177 # Property type dummies178 for pt in mdf["property_type"].unique():179 mdf[f"ptype_{pt}"] = (mdf["property_type"] == pt).astype(int)180 # Fill remaining NaN in numeric features with median181 for col in NUMERIC_FEATURES:182 mdf[col] = mdf[col].fillna(mdf[col].median())183 return mdf184185186@st.cache_resource187def fit_ols_model():188 """Fit an OLS hedonic model on the full raw dataset."""189 from sklearn.linear_model import LinearRegression190191 mdf = _prepare_model_df(raw_df)192 feature_names = list(FEATURE_COLS)193 city_cols = [c for c in mdf.columns if c.startswith("city_")]194 ptype_cols = [c for c in mdf.columns if c.startswith("ptype_")]195 feature_names = FEATURE_COLS + city_cols + ptype_cols196 X = mdf[feature_names].values197 y = mdf["log_price"].values198 model = LinearRegression()199 model.fit(X, y)200 return model, feature_names, mdf201202203@st.cache_resource204def fit_gbm_model():205 """Fit a gradient-boosted model for prediction & SHAP."""206 try:207 from sklearn.ensemble import GradientBoostingRegressor208 except ImportError:209 return None, None, None210211 mdf = _prepare_model_df(raw_df)212 feature_names = list(FEATURE_COLS)213 city_cols = sorted([c for c in mdf.columns if c.startswith("city_")])214 ptype_cols = sorted([c for c in mdf.columns if c.startswith("ptype_")])215 feature_names = FEATURE_COLS + city_cols + ptype_cols216 X = mdf[feature_names].values217 y = mdf["log_price"].values218 model = GradientBoostingRegressor(219 n_estimators=200,220 max_depth=5,221 learning_rate=0.1,222 subsample=0.8,223 random_state=42,224 )225 model.fit(X, y)226 return model, feature_names, mdf227228229# ---------------------------------------------------------------------------230# TABS231# ---------------------------------------------------------------------------232tab1, tab2, tab3, tab4, tab5, tab6, tab7 = st.tabs(233 [234 "Data Explorer",235 "Price Map",236 "Neighbourhood Comparison",237 "Hedonic Premiums",238 "Price Predictor",239 "SHAP Explorer",240 "Price Decomposition",241 ]242)243244# ===================================================================245# TAB 1 - Data Explorer246# ===================================================================247with tab1:248 st.header("Data Explorer")249250 # Summary metrics251 col1, col2, col3, col4, col5 = st.columns(5)252 with col1:253 st.metric("Listings", f"{len(df):,}")254 with col2:255 st.metric("Cities", f"{df['city'].nunique()}")256 with col3:257 median_price = df["price_numeric"].median()258 st.metric("Median Price", f"${median_price:,.0f}" if pd.notna(median_price) else "N/A")259 with col4:260 avg_rating = df["rating"].mean()261 st.metric("Avg Rating", f"{avg_rating:.2f}" if pd.notna(avg_rating) else "N/A")262 with col5:263 superhost_pct = df["is_superhost"].mean() * 100264 st.metric("Superhosts", f"{superhost_pct:.1f}%")265266 st.markdown("---")267268 # Summary statistics table269 st.subheader("Summary Statistics")270 numeric_cols = [271 "price_numeric", "rating", "bedrooms", "bathrooms",272 "guests_count", "amenities_count", "num_images", "num_reviews", "quality_score",273 ]274 summary = df[numeric_cols].describe().T275 summary.columns = ["Count", "Mean", "Std", "Min", "25%", "50%", "75%", "Max"]276 st.dataframe(summary.style.format("{:.2f}"), use_container_width=True)277278 st.markdown("---")279280 # Price distribution281 st.subheader("Price Distribution")282 price_data = df["price_numeric"].dropna()283 if len(price_data) > 0:284 cap = st.slider(285 "Upper price cap for histogram",286 min_value=100,287 max_value=5000,288 value=1500,289 step=50,290 key="price_hist_cap",291 )292 fig_hist = px.histogram(293 price_data[price_data <= cap],294 nbins=60,295 title="Price Distribution (capped)",296 labels={"value": "Price ($)", "count": "Frequency"},297 color_discrete_sequence=["#FF5A5F"],298 )299 fig_hist.update_layout(300 showlegend=False,301 xaxis_title="Price ($)",302 yaxis_title="Count",303 template="plotly_white",304 )305 st.plotly_chart(fig_hist, use_container_width=True)306 else:307 st.info("No price data available for the current filter selection.")308309 st.markdown("---")310311 # Filtered data table312 st.subheader("Filtered Data")313 display_cols = [314 "name", "city", "property_type", "price_numeric", "rating",315 "bedrooms", "bathrooms", "guests_count", "amenities_count",316 "is_superhost", "pets_allowed",317 ]318 st.dataframe(319 df[display_cols].reset_index(drop=True),320 use_container_width=True,321 height=400,322 )323324# ===================================================================325# TAB 2 - Price Map326# ===================================================================327with tab2:328 st.header("Price Map")329330 map_df = df.dropna(subset=["price_numeric", "lat", "long"]).copy()331 map_df = map_df[(map_df["lat"] != 0) & (map_df["long"] != 0)]332333 if len(map_df) == 0:334 st.warning("No geolocated listings with price data in current filter.")335 else:336 map_col1, map_col2 = st.columns([1, 3])337 with map_col1:338 color_by = st.radio(339 "Color by", ["price_numeric", "log_price", "rating"], index=0, key="map_color"340 )341 map_price_cap = st.slider(342 "Price cap for map color scale",343 100, 5000, 2000, 100,344 key="map_cap",345 )346347 map_plot = map_df.copy()348 if color_by == "price_numeric":349 map_plot["color_val"] = map_plot["price_numeric"].clip(upper=map_price_cap)350 elif color_by == "log_price":351 map_plot["color_val"] = map_plot["log_price"]352 else:353 map_plot["color_val"] = map_plot["rating"]354355 map_plot["size_val"] = map_plot["guests_count"].clip(lower=1, upper=16)356357 with map_col2:358 fig_map = px.scatter_map(359 map_plot,360 lat="lat",361 lon="long",362 color="color_val",363 size="size_val",364 color_continuous_scale="RdYlGn_r" if "price" in color_by else "Viridis",365 size_max=14,366 zoom=5,367 hover_name="name",368 hover_data={369 "city": True,370 "property_type": True,371 "price_numeric": ":.0f",372 "rating": ":.2f",373 "guests_count": True,374 "color_val": False,375 "size_val": False,376 "lat": False,377 "long": False,378 },379 map_style="carto-positron",380 title="Listings Map",381 height=650,382 )383 fig_map.update_layout(384 coloraxis_colorbar_title=color_by.replace("_", " ").title(),385 margin=dict(l=0, r=0, t=40, b=0),386 )387 st.plotly_chart(fig_map, use_container_width=True)388389# ===================================================================390# TAB 3 - Neighbourhood Comparison391# ===================================================================392with tab3:393 st.header("Neighbourhood Comparison")394395 compare_cities = st.multiselect(396 "Select cities to compare",397 options=all_cities,398 default=top_cities[:8],399 key="compare_cities",400 )401402 if not compare_cities:403 st.info("Please select at least one city to compare.")404 else:405 cdf = raw_df[raw_df["city"].isin(compare_cities)].dropna(subset=["price_numeric"])406 cdf = cdf[cdf["price_numeric"] > 0]407408 if len(cdf) == 0:409 st.warning("No price data for the selected cities.")410 else:411 c1, c2 = st.columns(2)412413 with c1:414 st.subheader("Price Distribution by City")415 fig_box = px.box(416 cdf,417 x="city",418 y="price_numeric",419 color="city",420 title="Price Box Plot by City",421 labels={"price_numeric": "Price ($)", "city": "City"},422 color_discrete_sequence=px.colors.qualitative.Set2,423 )424 fig_box.update_layout(425 showlegend=False,426 template="plotly_white",427 yaxis=dict(range=[0, cdf["price_numeric"].quantile(0.95)]),428 )429 st.plotly_chart(fig_box, use_container_width=True)430431 with c2:432 st.subheader("Median Price by City")433 city_med = (434 cdf.groupby("city")["price_numeric"]435 .median()436 .sort_values(ascending=False)437 .reset_index()438 )439 city_med.columns = ["City", "Median Price"]440 fig_bar = px.bar(441 city_med,442 x="Median Price",443 y="City",444 orientation="h",445 title="Median Nightly Price by City",446 color="Median Price",447 color_continuous_scale="Sunset",448 )449 fig_bar.update_layout(template="plotly_white", yaxis=dict(autorange="reversed"))450 st.plotly_chart(fig_bar, use_container_width=True)451452 # Summary table453 st.subheader("City Summary Statistics")454 city_stats = (455 cdf.groupby("city")456 .agg(457 Listings=("price_numeric", "count"),458 Mean_Price=("price_numeric", "mean"),459 Median_Price=("price_numeric", "median"),460 Std_Price=("price_numeric", "std"),461 Avg_Rating=("rating", "mean"),462 Avg_Bedrooms=("bedrooms", "mean"),463 Superhost_Pct=("is_superhost", "mean"),464 )465 .round(2)466 )467 city_stats["Superhost_Pct"] = (city_stats["Superhost_Pct"] * 100).round(1)468 st.dataframe(city_stats.sort_values("Median_Price", ascending=False), use_container_width=True)469470# ===================================================================471# TAB 4 - Hedonic Premiums (OLS)472# ===================================================================473with tab4:474 st.header("Hedonic Premiums (OLS Regression)")475 st.markdown(476 """477 A semi-log hedonic model is estimated: **ln(Price) = X * beta + epsilon**.478 Coefficients are converted to percentage premiums via **(exp(beta) - 1) x 100**.479 """480 )481482 with st.spinner("Fitting OLS hedonic model..."):483 ols_model, ols_features, ols_mdf = fit_ols_model()484485 coefs = pd.DataFrame(486 {"Feature": ols_features, "Coefficient": ols_model.coef_}487 )488 coefs["Premium (%)"] = (np.exp(coefs["Coefficient"]) - 1) * 100489 coefs = coefs.sort_values("Premium (%)", ascending=False).reset_index(drop=True)490491 c1, c2 = st.columns([1, 2])492493 with c1:494 st.subheader("Coefficient Table")495 st.dataframe(496 coefs.style.format({"Coefficient": "{:.4f}", "Premium (%)": "{:.2f}"}),497 use_container_width=True,498 height=500,499 )500501 r2 = ols_model.score(502 ols_mdf[ols_features].values, ols_mdf["log_price"].values503 )504 st.metric("R-squared", f"{r2:.4f}")505 st.metric("N observations", f"{len(ols_mdf):,}")506507 with c2:508 st.subheader("Hedonic Premiums: Key Features")509 key_features = FEATURE_COLS + [510 f for f in ols_features if f.startswith("ptype_")511 ]512 key_coefs = coefs[coefs["Feature"].isin(key_features)].sort_values("Premium (%)")513514 fig_prem = px.bar(515 key_coefs,516 x="Premium (%)",517 y="Feature",518 orientation="h",519 color="Premium (%)",520 color_continuous_scale="RdYlGn",521 title="Estimated Price Premiums (%)",522 height=500,523 )524 fig_prem.update_layout(template="plotly_white")525 st.plotly_chart(fig_prem, use_container_width=True)526527 st.markdown("---")528 st.subheader("Interpretation")529 st.markdown(530 """531 - **Positive premiums** indicate features that command a higher nightly price.532 - **Negative premiums** suggest a discount relative to the baseline.533 - For continuous variables (e.g., bedrooms), the premium is *per unit increase*.534 - For binary variables (e.g., is_superhost), the premium is relative to a non-superhost listing.535 - City and property-type dummies capture location and structural fixed effects.536 """537 )538539# ===================================================================540# TAB 5 - Price Predictor541# ===================================================================542with tab5:543 st.header("Price Predictor")544 st.markdown("Adjust the inputs below to predict the nightly Airbnb price.")545546 with st.spinner("Loading prediction model..."):547 gbm_model, gbm_features, gbm_mdf = fit_gbm_model()548549 if gbm_model is None:550 st.error("Could not fit prediction model. Check dependencies.")551 else:552 pred_c1, pred_c2 = st.columns([1, 2])553554 with pred_c1:555 st.subheader("Listing Features")556 pred_city = st.selectbox("City", options=top_cities[:20], index=0, key="pred_city")557 pred_ptype = st.selectbox("Property Type", options=all_property_types, index=0, key="pred_ptype")558 pred_bedrooms = st.slider("Bedrooms", 1, 10, 2, key="pred_bed")559 pred_bathrooms = st.slider("Bathrooms", 0.0, 5.0, 1.0, 0.5, key="pred_bath")560 pred_guests = st.slider("Guests", 1, 16, 4, key="pred_guests")561 pred_amenities = st.slider("Amenities count", 0, 100, 30, key="pred_amen")562 pred_images = st.slider("Number of images", 1, 50, 15, key="pred_img")563 pred_rating = st.slider("Rating", 0.0, 5.0, 4.8, 0.01, key="pred_rat")564 pred_superhost = st.checkbox("Superhost", value=False, key="pred_sh")565 pred_fav = st.checkbox("Guest Favorite", value=False, key="pred_fav")566 pred_pets = st.checkbox("Pets Allowed", value=False, key="pred_pets")567568 # Build feature vector569 input_dict = {570 "bedrooms": pred_bedrooms,571 "bathrooms": pred_bathrooms,572 "guests_count": pred_guests,573 "amenities_count": pred_amenities,574 "num_images": pred_images,575 "rating": pred_rating,576 "is_superhost": int(pred_superhost),577 "is_guest_favorite": int(pred_fav),578 "pets_allowed": int(pred_pets),579 }580 # City dummies581 city_cols = [f for f in gbm_features if f.startswith("city_")]582 for c in city_cols:583 input_dict[c] = 1 if c == f"city_{pred_city}" else 0584 # Property type dummies585 ptype_cols = [f for f in gbm_features if f.startswith("ptype_")]586 for p in ptype_cols:587 input_dict[p] = 1 if p == f"ptype_{pred_ptype}" else 0588589 X_pred = np.array([[input_dict.get(f, 0) for f in gbm_features]])590 log_pred = gbm_model.predict(X_pred)[0]591 pred_price = np.exp(log_pred)592593 # Approximate confidence interval via training residuals594 y_train = gbm_mdf["log_price"].values595 y_hat_train = gbm_model.predict(gbm_mdf[gbm_features].values)596 residual_std = np.std(y_train - y_hat_train)597 ci_low = np.exp(log_pred - 1.96 * residual_std)598 ci_high = np.exp(log_pred + 1.96 * residual_std)599600 with pred_c2:601 st.subheader("Predicted Price")602603 m1, m2, m3 = st.columns(3)604 with m1:605 st.metric("Lower 95% CI", f"${ci_low:,.0f}")606 with m2:607 st.metric("Predicted Price", f"${pred_price:,.0f}")608 with m3:609 st.metric("Upper 95% CI", f"${ci_high:,.0f}")610611 # Feature contribution breakdown612 st.subheader("Feature Contribution Breakdown")613 st.markdown(614 "Approximate marginal contribution of each feature group "615 "(setting each to its baseline and measuring the price change)."616 )617618 # Compute contributions by zeroing out each feature619 baseline_dict = {}620 for f in gbm_features:621 baseline_dict[f] = gbm_mdf[f].median() if f in gbm_mdf.columns else 0622 X_base = np.array([[baseline_dict.get(f, 0) for f in gbm_features]])623 base_log = gbm_model.predict(X_base)[0]624625 contributions = {}626 feature_groups = {627 "Bedrooms": ["bedrooms"],628 "Bathrooms": ["bathrooms"],629 "Guests": ["guests_count"],630 "Amenities": ["amenities_count"],631 "Images": ["num_images"],632 "Rating": ["rating"],633 "Superhost": ["is_superhost"],634 "Guest Favorite": ["is_guest_favorite"],635 "Pets Allowed": ["pets_allowed"],636 "City": city_cols,637 "Property Type": ptype_cols,638 }639640 for group_name, cols in feature_groups.items():641 X_modified = X_pred.copy()642 for col_name in cols:643 idx = gbm_features.index(col_name)644 X_modified[0, idx] = baseline_dict.get(col_name, 0)645 modified_log = gbm_model.predict(X_modified)[0]646 # Contribution = what the feature adds relative to baseline647 contributions[group_name] = (log_pred - modified_log) * pred_price648649 contrib_df = pd.DataFrame(650 {"Feature": list(contributions.keys()), "Contribution ($)": list(contributions.values())}651 ).sort_values("Contribution ($)")652653 fig_contrib = px.bar(654 contrib_df,655 x="Contribution ($)",656 y="Feature",657 orientation="h",658 color="Contribution ($)",659 color_continuous_scale="RdYlGn",660 title="Feature Contributions to Predicted Price",661 )662 fig_contrib.update_layout(template="plotly_white", height=420)663 st.plotly_chart(fig_contrib, use_container_width=True)664665# ===================================================================666# TAB 6 - SHAP Explorer667# ===================================================================668with tab6:669 st.header("SHAP Explorer")670671 shap_available = True672 try:673 import shap674 except ImportError:675 shap_available = False676677 if not shap_available:678 st.error(679 "The `shap` library is not installed. "680 "Install it with `pip install shap` to enable this tab."681 )682 else:683 with st.spinner("Computing SHAP values (this may take a moment)..."):684 gbm_model_s, gbm_features_s, gbm_mdf_s = fit_gbm_model()685686 if gbm_model_s is None:687 st.error("Could not load model for SHAP analysis.")688 else:689 @st.cache_data690 def compute_shap_values():691 _model, _features, _mdf = fit_gbm_model()692 X_shap = _mdf[_features].values693 # Use a sample if dataset is large694 sample_size = min(500, len(X_shap))695 rng = np.random.RandomState(42)696 idx = rng.choice(len(X_shap), sample_size, replace=False)697 X_sample = X_shap[idx]698 explainer = shap.TreeExplainer(_model)699 sv = explainer.shap_values(X_sample)700 return sv, X_sample, _features701702 shap_vals, X_shap_sample, shap_feature_names = compute_shap_values()703704 # SHAP Summary Plot705 st.subheader("SHAP Summary Plot")706 fig_shap_summary, ax_shap = plt.subplots(figsize=(10, 7))707 shap.summary_plot(708 shap_vals,709 X_shap_sample,710 feature_names=shap_feature_names,711 show=False,712 max_display=20,713 )714 plt.tight_layout()715 st.pyplot(fig_shap_summary)716 plt.close(fig_shap_summary)717718 # Feature importance ranking719 st.subheader("Feature Importance Ranking (Mean |SHAP|)")720 mean_abs_shap = np.abs(shap_vals).mean(axis=0)721 imp_df = pd.DataFrame(722 {"Feature": shap_feature_names, "Mean |SHAP|": mean_abs_shap}723 ).sort_values("Mean |SHAP|", ascending=False).head(20)724725 fig_imp = px.bar(726 imp_df,727 x="Mean |SHAP|",728 y="Feature",729 orientation="h",730 color="Mean |SHAP|",731 color_continuous_scale="Viridis",732 title="Top 20 Features by SHAP Importance",733 )734 fig_imp.update_layout(template="plotly_white", yaxis=dict(autorange="reversed"))735 st.plotly_chart(fig_imp, use_container_width=True)736737 # SHAP Dependence Plot738 st.subheader("SHAP Dependence Plot")739 dep_feature = st.selectbox(740 "Select feature for dependence plot",741 options=shap_feature_names,742 index=shap_feature_names.index("bedrooms") if "bedrooms" in shap_feature_names else 0,743 key="shap_dep_feature",744 )745 dep_idx = shap_feature_names.index(dep_feature)746 fig_dep, ax_dep = plt.subplots(figsize=(9, 5))747 shap.dependence_plot(748 dep_idx,749 shap_vals,750 X_shap_sample,751 feature_names=shap_feature_names,752 ax=ax_dep,753 show=False,754 )755 plt.tight_layout()756 st.pyplot(fig_dep)757 plt.close(fig_dep)758759# ===================================================================760# TAB 7 - Price Decomposition761# ===================================================================762with tab7:763 st.header("Price Decomposition")764 st.markdown("Select a listing to see its predicted price breakdown into component contributions.")765766 gbm_model_d, gbm_features_d, gbm_mdf_d = fit_gbm_model()767768 if gbm_model_d is None:769 st.error("Prediction model not available.")770 else:771 # Build listing selector from rows with valid prices772 decomp_df = raw_df.dropna(subset=["price_numeric"]).copy()773 decomp_df = decomp_df[decomp_df["price_numeric"] > 0]774 decomp_df["label"] = (775 decomp_df["name"].str[:60]776 + " | "777 + decomp_df["city"]778 + " | $"779 + decomp_df["price_numeric"].round(0).astype(int).astype(str)780 )781782 selected_label = st.selectbox(783 "Select a listing",784 options=decomp_df["label"].tolist()[:500],785 index=0,786 key="decomp_listing",787 )788789 sel_row = decomp_df[decomp_df["label"] == selected_label].iloc[0]790791 # Prepare features for this listing792 mdf_full = _prepare_model_df(raw_df)793 city_cols_d = sorted([c for c in gbm_features_d if c.startswith("city_")])794 ptype_cols_d = sorted([c for c in gbm_features_d if c.startswith("ptype_")])795796 listing_features = {}797 for f in FEATURE_COLS:798 val = sel_row[f]799 if f in BOOL_FEATURES:800 listing_features[f] = int(val)801 else:802 listing_features[f] = float(val) if pd.notna(val) else mdf_full[f].median()803804 for c in city_cols_d:805 city_name = c.replace("city_", "")806 listing_features[c] = 1 if sel_row["city"] == city_name else 0807 for p in ptype_cols_d:808 ptype_name = p.replace("ptype_", "")809 listing_features[p] = 1 if sel_row["property_type"] == ptype_name else 0810811 X_listing = np.array([[listing_features.get(f, 0) for f in gbm_features_d]])812 log_pred_d = gbm_model_d.predict(X_listing)[0]813 pred_price_d = np.exp(log_pred_d)814 actual_price_d = sel_row["price_numeric"]815816 # Metrics817 dc1, dc2, dc3 = st.columns(3)818 with dc1:819 st.metric("Actual Price", f"${actual_price_d:,.0f}")820 with dc2:821 st.metric("Predicted Price", f"${pred_price_d:,.0f}")822 with dc3:823 diff_pct = ((pred_price_d - actual_price_d) / actual_price_d) * 100824 st.metric("Prediction Error", f"{diff_pct:+.1f}%")825826 st.markdown("---")827828 # Decomposition: compute contribution of each feature group829 baseline_d = {}830 for f in gbm_features_d:831 baseline_d[f] = mdf_full[f].median() if f in mdf_full.columns else 0832 X_base_d = np.array([[baseline_d.get(f, 0) for f in gbm_features_d]])833 base_log_d = gbm_model_d.predict(X_base_d)[0]834 base_price_d = np.exp(base_log_d)835836 feature_groups_d = {837 "Bedrooms": ["bedrooms"],838 "Bathrooms": ["bathrooms"],839 "Guests": ["guests_count"],840 "Amenities": ["amenities_count"],841 "Images": ["num_images"],842 "Rating": ["rating"],843 "Superhost": ["is_superhost"],844 "Guest Favorite": ["is_guest_favorite"],845 "Pets Allowed": ["pets_allowed"],846 "City": city_cols_d,847 "Property Type": ptype_cols_d,848 }849850 contribs_d = {}851 for group_name, cols in feature_groups_d.items():852 X_mod = X_listing.copy()853 for col_name in cols:854 if col_name in gbm_features_d:855 idx = gbm_features_d.index(col_name)856 X_mod[0, idx] = baseline_d.get(col_name, 0)857 mod_log = gbm_model_d.predict(X_mod)[0]858 contribs_d[group_name] = (log_pred_d - mod_log)859860 contribs_d["Baseline"] = base_log_d861862 # Convert to dollar contributions (approximate)863 total_log = sum(contribs_d.values())864 scale = pred_price_d / np.exp(total_log) if total_log != 0 else 1865866 dollar_contribs = {}867 for k, v in contribs_d.items():868 if k == "Baseline":869 dollar_contribs[k] = base_price_d870 else:871 dollar_contribs[k] = v * pred_price_d872873 dc_df = pd.DataFrame(874 {"Component": list(dollar_contribs.keys()), "Contribution ($)": list(dollar_contribs.values())}875 )876877 col_decomp1, col_decomp2 = st.columns(2)878879 with col_decomp1:880 st.subheader("Price Component Breakdown")881 non_baseline = dc_df[dc_df["Component"] != "Baseline"].sort_values(882 "Contribution ($)", ascending=True883 )884 fig_stack = px.bar(885 non_baseline,886 x="Contribution ($)",887 y="Component",888 orientation="h",889 color="Contribution ($)",890 color_continuous_scale="RdYlGn",891 title="Feature Contributions to Predicted Price",892 )893 fig_stack.update_layout(template="plotly_white", height=450)894 st.plotly_chart(fig_stack, use_container_width=True)895896 with col_decomp2:897 st.subheader("Top Drivers")898899 positive = non_baseline[non_baseline["Contribution ($)"] > 0].sort_values(900 "Contribution ($)", ascending=False901 )902 negative = non_baseline[non_baseline["Contribution ($)"] < 0].sort_values(903 "Contribution ($)", ascending=True904 )905906 st.markdown("**Top Positive Drivers**")907 if len(positive) > 0:908 for _, row in positive.head(5).iterrows():909 st.write(f" + **{row['Component']}**: +${row['Contribution ($)']:,.0f}")910 else:911 st.write(" None")912913 st.markdown("**Top Negative Drivers**")914 if len(negative) > 0:915 for _, row in negative.head(5).iterrows():916 st.write(f" - **{row['Component']}**: -${abs(row['Contribution ($)']):,.0f}")917 else:918 st.write(" None")919920 st.markdown("---")921 st.markdown(f"**Baseline price (median listing):** ${base_price_d:,.0f}")922 st.markdown(f"**Predicted price for this listing:** ${pred_price_d:,.0f}")923