SPB Git forge

spb/wp4_uqo

Public
3commits 1branches 1releases
5.4 MBsize
maindefault branch
1 h agolast push
Python 64.6% TeX 23.3% HTML 11.6% Makefile 0.5%
33.2 KB · 923 lines python
Raw Blame History
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