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%
25.1 KB · 750 lines python
Raw Blame History
1"""2figures.py - Publication-quality figure generation for hedonic pricing analysis.34All figures use matplotlib + seaborn with a consistent 'whitegrid' style,5(10, 6) default size, 300 DPI, and are saved as both PNG and PDF for6LaTeX / journal submission compatibility.78Author: Simon-Pierre Boucher9Date: 2026-05-2010"""1112from __future__ import annotations1314import logging15import os16from pathlib import Path17from typing import Any, Dict, List, Optional, Sequence, Tuple, Union1819import matplotlib.pyplot as plt20import matplotlib.ticker as mticker21import numpy as np22import pandas as pd23import seaborn as sns2425logger = logging.getLogger(__name__)2627# ---------------------------------------------------------------------------28# Global style defaults29# ---------------------------------------------------------------------------3031_DEFAULT_FIGSIZE: Tuple[int, int] = (10, 6)32_DEFAULT_DPI: int = 30033_STYLE: str = "whitegrid"34_PALETTE: str = "muted"3536# Publication-quality font sizes37_RC_PARAMS: Dict[str, Any] = {38    "font.size": 11,39    "axes.titlesize": 13,40    "axes.labelsize": 12,41    "xtick.labelsize": 10,42    "ytick.labelsize": 10,43    "legend.fontsize": 10,44    "figure.titlesize": 14,45    "font.family": "serif",46}474849def _apply_style() -> None:50    """Apply consistent seaborn + matplotlib style."""51    sns.set_style(_STYLE)52    sns.set_palette(_PALETTE)53    plt.rcParams.update(_RC_PARAMS)545556def _save_figure(57    fig: plt.Figure,58    filename: str,59    save_dir: str,60) -> List[str]:61    """Save a figure as PNG and PDF, return list of saved paths."""62    os.makedirs(save_dir, exist_ok=True)63    paths: List[str] = []64    for ext in ("png", "pdf"):65        stem = Path(filename).stem66        fpath = os.path.join(save_dir, f"{stem}.{ext}")67        fig.savefig(fpath, dpi=_DEFAULT_DPI, bbox_inches="tight",68                    facecolor="white", edgecolor="none")69        paths.append(fpath)70        logger.info("Saved figure: %s", fpath)71    plt.close(fig)72    return paths737475# ---------------------------------------------------------------------------76# Figure 01 - Price distribution77# ---------------------------------------------------------------------------7879def plot_price_distribution(80    df: pd.DataFrame,81    save_dir: str,82    price_col: str = "price_numeric",83    figsize: Tuple[int, int] = _DEFAULT_FIGSIZE,84) -> List[str]:85    """86    01_price_distribution.png -- Histogram + KDE of raw prices.8788    Parameters89    ----------90    df : pd.DataFrame91    save_dir : str92    price_col : str93    figsize : tuple9495    Returns96    -------97    list[str]98        Paths to saved files.99    """100    _apply_style()101    prices = df[price_col].dropna()102103    fig, ax = plt.subplots(figsize=figsize)104    sns.histplot(prices, bins=80, kde=True, color="#4C72B0",105                 edgecolor="white", linewidth=0.5, ax=ax)106    ax.set_xlabel("Nightly Price (CAD)")107    ax.set_ylabel("Frequency")108    ax.set_title("Distribution of Airbnb Listing Prices")109110    # Annotate key statistics111    med = prices.median()112    mean = prices.mean()113    ax.axvline(med, color="#C44E52", linestyle="--", linewidth=1.2,114               label=f"Median = ${med:,.0f}")115    ax.axvline(mean, color="#DD8452", linestyle="--", linewidth=1.2,116               label=f"Mean = ${mean:,.0f}")117    ax.legend(frameon=True, fancybox=False, edgecolor="0.8")118119    ax.xaxis.set_major_formatter(mticker.StrMethodFormatter("${x:,.0f}"))120    fig.tight_layout()121    return _save_figure(fig, "01_price_distribution", save_dir)122123124# ---------------------------------------------------------------------------125# Figure 02 - Log-price distribution126# ---------------------------------------------------------------------------127128def plot_log_price_distribution(129    df: pd.DataFrame,130    save_dir: str,131    price_col: str = "price_numeric",132    figsize: Tuple[int, int] = _DEFAULT_FIGSIZE,133) -> List[str]:134    """02_log_price_distribution.png -- Histogram + KDE of ln(price)."""135    _apply_style()136    prices = df[price_col].dropna()137    log_prices = np.log(prices[prices > 0])138139    fig, ax = plt.subplots(figsize=figsize)140    sns.histplot(log_prices, bins=60, kde=True, color="#55A868",141                 edgecolor="white", linewidth=0.5, ax=ax)142    ax.set_xlabel("ln(Nightly Price)")143    ax.set_ylabel("Frequency")144    ax.set_title("Distribution of Log-Transformed Prices\n"145                 "(Semi-log hedonic model dependent variable)")146147    med = log_prices.median()148    ax.axvline(med, color="#C44E52", linestyle="--", linewidth=1.2,149               label=f"Median ln(price) = {med:.2f}")150    ax.legend(frameon=True, fancybox=False, edgecolor="0.8")151152    # Shapiro-Wilk note (sample if too large)153    from scipy import stats as sp_stats154    sample = log_prices.sample(min(5000, len(log_prices)), random_state=42)155    _, sw_p = sp_stats.shapiro(sample)156    ax.annotate(f"Shapiro-Wilk p = {sw_p:.4f} (n={len(sample)})",157                xy=(0.98, 0.95), xycoords="axes fraction",158                ha="right", va="top", fontsize=9,159                bbox=dict(boxstyle="round,pad=0.3", fc="white",160                          ec="0.8", alpha=0.9))161162    fig.tight_layout()163    return _save_figure(fig, "02_log_price_distribution", save_dir)164165166# ---------------------------------------------------------------------------167# Figure 03 - Price map168# ---------------------------------------------------------------------------169170def plot_price_map(171    df: pd.DataFrame,172    save_dir: str,173    price_col: str = "price_numeric",174    lat_col: str = "lat",175    lon_col: str = "long",176    figsize: Tuple[int, int] = (12, 10),177) -> List[str]:178    """03_price_map.png -- Scatter on lat/long coloured by price."""179    _apply_style()180    sub = df.dropna(subset=[price_col, lat_col, lon_col]).copy()181182    # Cap at 99th percentile for colour scale183    p99 = sub[price_col].quantile(0.99)184    sub["price_capped"] = sub[price_col].clip(upper=p99)185186    fig, ax = plt.subplots(figsize=figsize)187    scatter = ax.scatter(188        sub[lon_col], sub[lat_col],189        c=sub["price_capped"], cmap="RdYlGn_r",190        s=8, alpha=0.6, edgecolors="none",191    )192    cbar = fig.colorbar(scatter, ax=ax, pad=0.02, shrink=0.8)193    cbar.set_label("Nightly Price (CAD)", fontsize=11)194195    ax.set_xlabel("Longitude")196    ax.set_ylabel("Latitude")197    ax.set_title("Spatial Distribution of Airbnb Prices -- Quebec")198199    fig.tight_layout()200    return _save_figure(fig, "03_price_map", save_dir)201202203# ---------------------------------------------------------------------------204# Figure 04 - Price by neighbourhood / city205# ---------------------------------------------------------------------------206207def plot_price_by_neighbourhood(208    df: pd.DataFrame,209    save_dir: str,210    price_col: str = "price_numeric",211    geo_col: str = "city",212    top_n: int = 20,213    figsize: Tuple[int, int] = (12, 7),214) -> List[str]:215    """04_price_by_neighbourhood.png -- Top 20 cities/neighbourhoods."""216    _apply_style()217    sub = df.dropna(subset=[price_col, geo_col]).copy()218219    # Top N by listing count220    top_areas = (221        sub[geo_col].value_counts().head(top_n).index.tolist()222    )223    sub = sub[sub[geo_col].isin(top_areas)]224225    # Order by median price226    order = (227        sub.groupby(geo_col, observed=True)[price_col]228        .median().sort_values(ascending=False).index.tolist()229    )230231    fig, ax = plt.subplots(figsize=figsize)232    sns.boxplot(233        data=sub, y=geo_col, x=price_col, order=order,234        palette="coolwarm", showfliers=False, ax=ax,235        linewidth=0.8,236    )237    ax.set_xlabel("Nightly Price (CAD)")238    ax.set_ylabel("")239    ax.set_title(f"Price Distribution by Top {top_n} Cities / Neighbourhoods")240    ax.xaxis.set_major_formatter(mticker.StrMethodFormatter("${x:,.0f}"))241242    fig.tight_layout()243    return _save_figure(fig, "04_price_by_neighbourhood", save_dir)244245246# ---------------------------------------------------------------------------247# Figure 05 - Price by room type248# ---------------------------------------------------------------------------249250def plot_price_by_room_type(251    df: pd.DataFrame,252    save_dir: str,253    price_col: str = "price_numeric",254    room_col: str = "property_type",255    figsize: Tuple[int, int] = _DEFAULT_FIGSIZE,256) -> List[str]:257    """05_price_by_room_type.png -- Violin + strip plot by room/property type."""258    _apply_style()259    sub = df.dropna(subset=[price_col, room_col]).copy()260261    # Keep types with enough observations262    counts = sub[room_col].value_counts()263    valid_types = counts[counts >= 10].index.tolist()264    sub = sub[sub[room_col].isin(valid_types)]265266    order = (267        sub.groupby(room_col, observed=True)[price_col]268        .median().sort_values(ascending=False).index.tolist()269    )270271    fig, ax = plt.subplots(figsize=figsize)272    sns.violinplot(273        data=sub, y=room_col, x=price_col, order=order,274        palette="Set2", inner="quartile", cut=0, ax=ax,275        linewidth=0.8,276    )277    ax.set_xlabel("Nightly Price (CAD)")278    ax.set_ylabel("")279    ax.set_title("Price Distribution by Property Type")280    ax.xaxis.set_major_formatter(mticker.StrMethodFormatter("${x:,.0f}"))281282    fig.tight_layout()283    return _save_figure(fig, "05_price_by_room_type", save_dir)284285286# ---------------------------------------------------------------------------287# Figure 06 - Amenity premiums288# ---------------------------------------------------------------------------289290def plot_amenity_premiums(291    premiums_df: pd.DataFrame,292    save_dir: str,293    top_n: int = 20,294    figsize: Tuple[int, int] = (10, 8),295) -> List[str]:296    """06_amenity_premiums.png -- Horizontal bar chart of % premiums."""297    _apply_style()298    df = premiums_df.copy()299300    # Normalise column names301    if "% Premium" not in df.columns:302        for c in df.columns:303            if "premium" in c.lower() or "effect" in c.lower():304                df = df.rename(columns={c: "% Premium"})305                break306    if "Amenity" not in df.columns:307        for c in df.columns:308            if "variable" in c.lower() or "amenity" in c.lower() or "feature" in c.lower():309                df = df.rename(columns={c: "Amenity"})310                break311312    df = df.sort_values("% Premium", ascending=True).tail(top_n)313314    fig, ax = plt.subplots(figsize=figsize)315    colours = ["#C44E52" if v < 0 else "#55A868" for v in df["% Premium"]]316    ax.barh(df["Amenity"], df["% Premium"], color=colours, edgecolor="white",317            linewidth=0.5)318    ax.set_xlabel("Price Premium (%)")319    ax.set_ylabel("")320    ax.set_title("Estimated Amenity Price Premiums\n(from semi-log hedonic model)")321    ax.axvline(0, color="black", linewidth=0.8)322323    # Value labels324    for i, (val, name) in enumerate(zip(df["% Premium"], df["Amenity"])):325        ax.text(val + (0.3 if val >= 0 else -0.3), i,326                f"{val:+.1f}%", va="center",327                ha="left" if val >= 0 else "right", fontsize=9)328329    fig.tight_layout()330    return _save_figure(fig, "06_amenity_premiums", save_dir)331332333# ---------------------------------------------------------------------------334# Figure 07 - Review score premium335# ---------------------------------------------------------------------------336337def plot_review_score_premium(338    df: pd.DataFrame,339    save_dir: str,340    price_col: str = "price_numeric",341    rating_col: str = "rating",342    figsize: Tuple[int, int] = _DEFAULT_FIGSIZE,343) -> List[str]:344    """07_review_score_premium.png -- Price vs. review score."""345    _apply_style()346    sub = df.dropna(subset=[price_col, rating_col]).copy()347    sub = sub[sub[price_col] > 0]348349    fig, axes = plt.subplots(1, 2, figsize=(14, 6))350351    # Left: scatter + regression line352    ax = axes[0]353    sns.regplot(354        data=sub, x=rating_col, y=price_col,355        scatter_kws={"alpha": 0.15, "s": 10, "color": "#4C72B0"},356        line_kws={"color": "#C44E52", "linewidth": 1.5},357        ax=ax, ci=95,358    )359    ax.set_xlabel("Review Rating")360    ax.set_ylabel("Nightly Price (CAD)")361    ax.set_title("Price vs. Review Score")362    ax.yaxis.set_major_formatter(mticker.StrMethodFormatter("${x:,.0f}"))363364    # Right: binned means with error bars365    ax = axes[1]366    sub["rating_bin"] = pd.cut(sub[rating_col], bins=10)367    binned = sub.groupby("rating_bin", observed=True)[price_col].agg(368        ["mean", "sem", "count"]369    ).dropna()370    binned["mid"] = [interval.mid for interval in binned.index]371    ax.errorbar(binned["mid"], binned["mean"], yerr=1.96 * binned["sem"],372                fmt="o-", color="#4C72B0", capsize=3, markersize=5)373    ax.set_xlabel("Review Rating (binned)")374    ax.set_ylabel("Mean Price (CAD)")375    ax.set_title("Mean Price by Rating Bin (95% CI)")376    ax.yaxis.set_major_formatter(mticker.StrMethodFormatter("${x:,.0f}"))377378    fig.suptitle("Review Score and Price Relationship", y=1.02, fontsize=14)379    fig.tight_layout()380    return _save_figure(fig, "07_review_score_premium", save_dir)381382383# ---------------------------------------------------------------------------384# Figure 08 - Distance gradient385# ---------------------------------------------------------------------------386387def plot_distance_gradient(388    df: pd.DataFrame,389    save_dir: str,390    price_col: str = "price_numeric",391    distance_col: Optional[str] = None,392    lat_col: str = "lat",393    lon_col: str = "long",394    center_lat: Optional[float] = None,395    center_lon: Optional[float] = None,396    figsize: Tuple[int, int] = _DEFAULT_FIGSIZE,397) -> List[str]:398    """399    08_distance_to_center_gradient.png -- Scatter + LOWESS of price vs400    distance to city centre.401402    If no distance column exists, one is computed from lat/long using a403    Haversine approximation to the centroid (or a supplied centre).404    """405    _apply_style()406    sub = df.dropna(subset=[price_col, lat_col, lon_col]).copy()407    sub = sub[sub[price_col] > 0]408409    if distance_col and distance_col in sub.columns:410        dist = sub[distance_col]411    else:412        # Compute distance to centroid (Haversine)413        if center_lat is None:414            center_lat = sub[lat_col].median()415        if center_lon is None:416            center_lon = sub[lon_col].median()417418        def _haversine_km(lat1: float, lon1: float,419                          lat2: float, lon2: float) -> float:420            R = 6371.0421            dlat = np.radians(lat2 - lat1)422            dlon = np.radians(lon2 - lon1)423            a = (np.sin(dlat / 2) ** 2 +424                 np.cos(np.radians(lat1)) * np.cos(np.radians(lat2)) *425                 np.sin(dlon / 2) ** 2)426            return R * 2 * np.arctan2(np.sqrt(a), np.sqrt(1 - a))427428        dist = sub.apply(429            lambda r: _haversine_km(r[lat_col], r[lon_col],430                                    center_lat, center_lon),431            axis=1,432        )433        distance_col = "dist_to_center_km"434        sub[distance_col] = dist435436    fig, ax = plt.subplots(figsize=figsize)437    ax.scatter(sub[distance_col], sub[price_col],438               alpha=0.15, s=8, color="#4C72B0", edgecolors="none")439440    # LOWESS smoothing441    try:442        from statsmodels.nonparametric.smoothers_lowess import lowess443        sample = sub.sample(min(5000, len(sub)), random_state=42)444        lowess_result = lowess(445            sample[price_col].values,446            sample[distance_col].values,447            frac=0.3,448        )449        ax.plot(lowess_result[:, 0], lowess_result[:, 1],450                color="#C44E52", linewidth=2.5, label="LOWESS")451        ax.legend(frameon=True, fancybox=False, edgecolor="0.8")452    except ImportError:453        logger.warning("statsmodels not available; LOWESS line omitted.")454455    ax.set_xlabel("Distance to Centre (km)")456    ax.set_ylabel("Nightly Price (CAD)")457    ax.set_title("Price Gradient: Distance to City Centre\n"458                 "(negative gradient expected from urban economics theory)")459    ax.yaxis.set_major_formatter(mticker.StrMethodFormatter("${x:,.0f}"))460461    fig.tight_layout()462    return _save_figure(fig, "08_distance_to_center_gradient", save_dir)463464465# ---------------------------------------------------------------------------466# Figure 09 - Quantile coefficients467# ---------------------------------------------------------------------------468469def plot_quantile_coefficients(470    coef_df: pd.DataFrame,471    save_dir: str,472    variables: Optional[List[str]] = None,473    figsize: Tuple[int, int] = (12, 8),474) -> List[str]:475    """476    09_quantile_coefficients.png -- Coefficient evolution across quantiles.477478    Parameters479    ----------480    coef_df : pd.DataFrame481        Must contain columns 'quantile', 'variable', 'coef', and optionally482        'ci_lower', 'ci_upper'.483    """484    _apply_style()485486    if variables is None:487        variables = coef_df["variable"].unique().tolist()[:6]488489    n_vars = len(variables)490    ncols = min(3, n_vars)491    nrows = int(np.ceil(n_vars / ncols))492493    fig, axes = plt.subplots(nrows, ncols, figsize=figsize, squeeze=False)494495    for idx, var in enumerate(variables):496        ax = axes[idx // ncols, idx % ncols]497        var_data = coef_df[coef_df["variable"] == var].sort_values("quantile")498499        ax.plot(var_data["quantile"], var_data["coef"],500                "o-", color="#4C72B0", markersize=5, linewidth=1.5)501502        if "ci_lower" in var_data.columns and "ci_upper" in var_data.columns:503            ax.fill_between(504                var_data["quantile"],505                var_data["ci_lower"],506                var_data["ci_upper"],507                alpha=0.2, color="#4C72B0",508            )509510        ax.axhline(0, color="black", linewidth=0.5, linestyle="--")511        ax.set_title(var, fontsize=10)512        ax.set_xlabel("Quantile")513        ax.set_ylabel("Coefficient")514515    # Hide unused axes516    for idx in range(n_vars, nrows * ncols):517        axes[idx // ncols, idx % ncols].set_visible(False)518519    fig.suptitle("Quantile Regression Coefficients Across the Price Distribution",520                 y=1.02, fontsize=13)521    fig.tight_layout()522    return _save_figure(fig, "09_quantile_coefficients", save_dir)523524525# ---------------------------------------------------------------------------526# Figure 14 - Predicted vs actual527# ---------------------------------------------------------------------------528529def plot_predicted_vs_actual(530    y_true: Union[np.ndarray, pd.Series],531    y_pred: Union[np.ndarray, pd.Series],532    save_dir: str,533    model_name: str = "Model",534    figsize: Tuple[int, int] = _DEFAULT_FIGSIZE,535) -> List[str]:536    """14_predicted_vs_actual.png -- 45-degree line plot."""537    _apply_style()538539    y_true = np.asarray(y_true)540    y_pred = np.asarray(y_pred)541542    fig, ax = plt.subplots(figsize=figsize)543544    ax.scatter(y_true, y_pred, alpha=0.2, s=10, color="#4C72B0",545               edgecolors="none")546547    # 45-degree line548    lims = [549        min(y_true.min(), y_pred.min()),550        max(y_true.max(), y_pred.max()),551    ]552    ax.plot(lims, lims, "--", color="#C44E52", linewidth=1.5,553            label="Perfect prediction")554555    # Metrics556    from sklearn.metrics import r2_score, mean_absolute_error, \557        root_mean_squared_error558    r2 = r2_score(y_true, y_pred)559    mae = mean_absolute_error(y_true, y_pred)560    rmse = root_mean_squared_error(y_true, y_pred)561562    stats_text = f"$R^2$ = {r2:.4f}\nMAE = {mae:.2f}\nRMSE = {rmse:.2f}"563    ax.annotate(stats_text, xy=(0.05, 0.92), xycoords="axes fraction",564                fontsize=10, va="top",565                bbox=dict(boxstyle="round,pad=0.4", fc="white",566                          ec="0.8", alpha=0.9))567568    ax.set_xlabel("Actual Values")569    ax.set_ylabel("Predicted Values")570    ax.set_title(f"Predicted vs. Actual -- {model_name}")571    ax.legend(loc="lower right", frameon=True, fancybox=False, edgecolor="0.8")572573    fig.tight_layout()574    return _save_figure(fig, "14_predicted_vs_actual", save_dir)575576577# ---------------------------------------------------------------------------578# Figure 15 - Hedonic decomposition (stacked bar)579# ---------------------------------------------------------------------------580581def plot_hedonic_decomposition(582    decomp_df: pd.DataFrame,583    save_dir: str,584    figsize: Tuple[int, int] = (12, 7),585) -> List[str]:586    """587    15_hedonic_decomposition_example.png -- Stacked bar showing how588    predicted price is decomposed into attribute contributions.589590    Parameters591    ----------592    decomp_df : pd.DataFrame593        Rows = individual listings (or a small sample), columns = attribute594        categories (e.g., 'Location', 'Size', 'Amenities', 'Host',595        'Intercept'). Values = contribution to predicted ln(price) or price.596    """597    _apply_style()598599    df = decomp_df.copy()600    if len(df) > 15:601        df = df.head(15)602603    # Ensure listing labels604    if df.index.dtype == "int64":605        df.index = [f"Listing {i+1}" for i in range(len(df))]606607    fig, ax = plt.subplots(figsize=figsize)608609    # Build stacked bars (positive contributions only for clarity)610    bottom = np.zeros(len(df))611    colours = sns.color_palette("Set2", n_colors=len(df.columns))612613    for i, col in enumerate(df.columns):614        vals = df[col].values.astype(float)615        ax.barh(df.index, vals, left=bottom, color=colours[i],616                edgecolor="white", linewidth=0.5, label=col)617        bottom += np.maximum(vals, 0)618619    ax.set_xlabel("Contribution to Predicted Price")620    ax.set_ylabel("")621    ax.set_title("Hedonic Price Decomposition\n"622                 "(marginal contribution of each attribute category)")623    ax.legend(bbox_to_anchor=(1.01, 1), loc="upper left",624              frameon=True, fancybox=False, edgecolor="0.8")625626    fig.tight_layout()627    return _save_figure(fig, "15_hedonic_decomposition_example", save_dir)628629630# ---------------------------------------------------------------------------631# Master function632# ---------------------------------------------------------------------------633634def generate_all_figures(635    df: pd.DataFrame,636    results: Optional[Dict[str, Any]] = None,637    save_dir: Optional[str] = None,638    price_col: str = "price_numeric",639) -> Dict[str, List[str]]:640    """641    Generate all figures that can be produced from the dataset alone642    (figures requiring model results are skipped if *results* is None).643644    Parameters645    ----------646    df : pd.DataFrame647        Full analysis dataset.648    results : dict, optional649        ``{'premiums_df': pd.DataFrame, 'coef_df': pd.DataFrame,650        'y_true': array, 'y_pred': array, 'decomp_df': pd.DataFrame,651        'model_name': str}``.652    save_dir : str, optional653        Output directory; defaults to ``results/figures/``.654655    Returns656    -------657    dict658        ``{figure_name: [saved_paths]}``.659    """660    if save_dir is None:661        save_dir = os.path.join(662            os.path.dirname(os.path.dirname(os.path.abspath(__file__))),663            "results", "figures",664        )665666    if results is None:667        results = {}668669    output: Dict[str, List[str]] = {}670    logger.info("Generating all figures to %s", save_dir)671672    # Data-only figures673    try:674        output["01_price_distribution"] = plot_price_distribution(675            df, save_dir, price_col=price_col)676    except Exception as e:677        logger.error("Failed 01_price_distribution: %s", e)678679    try:680        output["02_log_price_distribution"] = plot_log_price_distribution(681            df, save_dir, price_col=price_col)682    except Exception as e:683        logger.error("Failed 02_log_price_distribution: %s", e)684685    try:686        output["03_price_map"] = plot_price_map(687            df, save_dir, price_col=price_col)688    except Exception as e:689        logger.error("Failed 03_price_map: %s", e)690691    try:692        output["04_price_by_neighbourhood"] = plot_price_by_neighbourhood(693            df, save_dir, price_col=price_col)694    except Exception as e:695        logger.error("Failed 04_price_by_neighbourhood: %s", e)696697    try:698        output["05_price_by_room_type"] = plot_price_by_room_type(699            df, save_dir, price_col=price_col)700    except Exception as e:701        logger.error("Failed 05_price_by_room_type: %s", e)702703    # Review score premium (data-only)704    try:705        output["07_review_score_premium"] = plot_review_score_premium(706            df, save_dir, price_col=price_col)707    except Exception as e:708        logger.error("Failed 07_review_score_premium: %s", e)709710    # Distance gradient (data-only)711    try:712        output["08_distance_gradient"] = plot_distance_gradient(713            df, save_dir, price_col=price_col)714    except Exception as e:715        logger.error("Failed 08_distance_gradient: %s", e)716717    # Model-dependent figures718    if "premiums_df" in results:719        try:720            output["06_amenity_premiums"] = plot_amenity_premiums(721                results["premiums_df"], save_dir)722        except Exception as e:723            logger.error("Failed 06_amenity_premiums: %s", e)724725    if "coef_df" in results:726        try:727            output["09_quantile_coefficients"] = plot_quantile_coefficients(728                results["coef_df"], save_dir)729        except Exception as e:730            logger.error("Failed 09_quantile_coefficients: %s", e)731732    if "y_true" in results and "y_pred" in results:733        try:734            output["14_predicted_vs_actual"] = plot_predicted_vs_actual(735                results["y_true"], results["y_pred"], save_dir,736                model_name=results.get("model_name", "Model"))737        except Exception as e:738            logger.error("Failed 14_predicted_vs_actual: %s", e)739740    if "decomp_df" in results:741        try:742            output["15_hedonic_decomposition"] = plot_hedonic_decomposition(743                results["decomp_df"], save_dir)744        except Exception as e:745            logger.error("Failed 15_hedonic_decomposition: %s", e)746747    logger.info("Figure generation complete: %d/%d succeeded.",748                len(output), 11)749    return output750