Python 64.6%
TeX 23.3%
HTML 11.6%
Makefile 0.5%
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