#!/usr/bin/env python3 """Master pipeline for Airbnb hedonic pricing analysis.""" from __future__ import annotations import argparse import logging import sys import time import traceback from contextlib import contextmanager from pathlib import Path from typing import Any, Dict, List, Optional import numpy as np import pandas as pd PROJECT_ROOT = Path(__file__).resolve().parent.parent if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) from src import config logger = logging.getLogger("run_analysis") def _setup_logging(output_dir: Path) -> None: output_dir.mkdir(parents=True, exist_ok=True) root = logging.getLogger() root.setLevel(logging.DEBUG) root.handlers.clear() fmt = logging.Formatter(config.LOG_FORMAT, datefmt=config.LOG_DATE_FORMAT) ch = logging.StreamHandler(sys.stdout) ch.setLevel(logging.INFO) ch.setFormatter(fmt) root.addHandler(ch) fh = logging.FileHandler(str(output_dir / "analysis.log"), mode="w", encoding="utf-8") fh.setLevel(logging.DEBUG) fh.setFormatter(fmt) root.addHandler(fh) @contextmanager def step_timer(step_name: str, timings: Dict[str, float]): logger.info("=" * 72) logger.info("STEP START : %s", step_name) logger.info("=" * 72) t0 = time.perf_counter() try: yield finally: elapsed = time.perf_counter() - t0 timings[step_name] = elapsed logger.info("STEP END : %s [%.2f s]", step_name, elapsed) # --------------------------------------------------------------------------- # Step 1: Load data # --------------------------------------------------------------------------- def step_load_data(data_file: Path) -> pd.DataFrame: logger.info("Loading data from %s", data_file) df = pd.read_csv(data_file) logger.info("Loaded %d rows x %d cols", *df.shape) return df # --------------------------------------------------------------------------- # Step 2: Clean prices # --------------------------------------------------------------------------- def step_clean_prices(df: pd.DataFrame) -> pd.DataFrame: from src.cleaning import run_full_cleaning, create_price_variants try: df = run_full_cleaning(df) logger.info("Full cleaning complete: %d rows, %d cols", *df.shape) except Exception: logger.warning("run_full_cleaning failed, applying fallback", exc_info=True) if "price_numeric" in df.columns: df = df[df["price_numeric"].notna()].copy() df = df[(df["price_numeric"] >= config.PRICE_FLOOR) & (df["price_numeric"] <= config.PRICE_CEILING)].copy() df["log_price"] = np.log(df["price_numeric"]) logger.info("Fallback cleaning: %d rows remain", len(df)) return df # --------------------------------------------------------------------------- # Step 3: Feature engineering # --------------------------------------------------------------------------- def step_engineer_features(df: pd.DataFrame, *, skip_nlp: bool = False) -> pd.DataFrame: # Ensure log_price exists if "log_price" not in df.columns and "price_numeric" in df.columns: df["log_price"] = np.log(df["price_numeric"].clip(lower=1)) # 3a. Core features try: from src.feature_engineering import create_all_features df = create_all_features(df) logger.info("Core features: %d cols", df.shape[1]) except Exception: logger.warning("feature_engineering failed", exc_info=True) # 3b. Amenity indices try: from src.amenities import create_amenity_indices df = create_amenity_indices(df) logger.info("Amenity indices added: %d cols", df.shape[1]) except Exception: logger.warning("amenity indices failed", exc_info=True) # 3c. Geospatial features try: from src.geospatial import compute_distance_features df = compute_distance_features(df) logger.info("Geospatial features: %d cols", df.shape[1]) except Exception: logger.warning("geospatial features failed", exc_info=True) # 3d. NLP features if not skip_nlp: try: from src.nlp_features import create_all_nlp_features df, price_words = create_all_nlp_features(df) logger.info("NLP features: %d cols", df.shape[1]) if price_words is not None: price_words.to_csv(config.OUTPUTS_DIR / "tables" / "price_associated_words.csv", index=False) logger.info("Price-associated words saved") except Exception: logger.warning("NLP features failed", exc_info=True) return df # --------------------------------------------------------------------------- # Step 4: Hedonic models # --------------------------------------------------------------------------- def step_hedonic_models(df: pd.DataFrame) -> Dict[str, Any]: from src.hedonic_models import build_model_sequence, compare_models, compute_price_premiums from src.tables import export_table y_col = "log_price" if y_col not in df.columns: logger.warning("No log_price column; skipping hedonic models") return {} results = build_model_sequence(df, y_col=y_col) logger.info("Hedonic models estimated: %s", list(results.keys())) if results: comparison = compare_models(results) export_table(comparison, "model_comparison", output_dir=str(config.OUTPUTS_DIR / "tables")) logger.info("Model comparison table:\n%s", comparison.to_string()) # Price premiums from full model full_key = [k for k in results.keys() if "Full" in k or "9" in k] if full_key: premiums = compute_price_premiums(results[full_key[-1]]) export_table(premiums, "price_premiums", output_dir=str(config.OUTPUTS_DIR / "tables")) logger.info("Price premiums:\n%s", premiums.to_string()) return results # --------------------------------------------------------------------------- # Step 5: Quantile models # --------------------------------------------------------------------------- def step_quantile_models(df: pd.DataFrame, x_cols: List[str]) -> Dict[str, Any]: from src.quantile_models import estimate_quantile_sequence, compare_quantile_coefficients y_col = "log_price" if y_col not in df.columns or not x_cols: logger.warning("No log_price or no x_cols; skipping quantile models") return {} results = estimate_quantile_sequence(df, y_col, x_cols) logger.info("Quantile models estimated: taus=%s", list(results.keys())) if results: coef_df = compare_quantile_coefficients(results) coef_df.to_csv(config.OUTPUTS_DIR / "tables" / "quantile_coefficients.csv", index=False) return results # --------------------------------------------------------------------------- # Step 6: ML models # --------------------------------------------------------------------------- def step_ml_models(df: pd.DataFrame, x_cols: List[str]) -> Dict[str, Any]: from src.ml_models import prepare_ml_data, train_all_models, compare_all_models from src.tables import export_table y_col = "log_price" if y_col not in df.columns or not x_cols: logger.warning("No log_price or no x_cols; skipping ML models") return {} X_train, X_test, y_train, y_test, feat_names = prepare_ml_data(df, y_col, x_cols) logger.info("ML data prepared: train=%d, test=%d, features=%d", len(X_train), len(X_test), len(feat_names)) models = train_all_models(X_train, y_train) logger.info("ML models trained: %s", list(models.keys())) if models: comparison = compare_all_models(models, X_test, y_test) export_table(comparison, "ml_comparison", output_dir=str(config.OUTPUTS_DIR / "tables")) logger.info("ML comparison:\n%s", comparison.to_string()) return { "models": models, "X_train": X_train, "X_test": X_test, "y_train": y_train, "y_test": y_test, "feature_names": feat_names, } # --------------------------------------------------------------------------- # Step 7: SHAP values # --------------------------------------------------------------------------- def step_shap_analysis(ml_data: Dict[str, Any]) -> Dict[str, Any]: from src.shap_analysis import compute_shap_values, plot_shap_summary, get_shap_importance, plot_partial_dependence if not ml_data or "models" not in ml_data: logger.warning("No ML models; skipping SHAP") return {} models = ml_data["models"] X_test = ml_data["X_test"] feat_names = ml_data["feature_names"] fig_dir = str(config.OUTPUTS_DIR / "figures") results = {} # Use best tree-based model for SHAP for model_name in ["xgboost", "lightgbm", "gradient_boosting", "random_forest"]: if model_name in models: model = models[model_name] try: shap_vals = compute_shap_values(model, X_test, feat_names) plot_shap_summary(shap_vals, X_test, feat_names, f"{fig_dir}/10_shap_summary") importance_df = get_shap_importance(shap_vals, feat_names) importance_df.to_csv(config.OUTPUTS_DIR / "tables" / "shap_importance.csv", index=False) logger.info("SHAP analysis complete for %s", model_name) # Partial dependence plots key_features = ["bedrooms", "bathrooms", "guests_count", "distance_to_center", "rating"] available_features = [f for f in key_features if f in feat_names] if available_features: feat_indices = [feat_names.index(f) for f in available_features] try: plot_partial_dependence(model, X_test, feat_indices, feat_names, f"{fig_dir}/11_partial_dependence") except Exception: logger.warning("PDP plot failed", exc_info=True) results["shap_values"] = shap_vals results["model_name"] = model_name results["importance"] = importance_df break except Exception: logger.warning("SHAP failed for %s", model_name, exc_info=True) return results # --------------------------------------------------------------------------- # Step 8: Spatial models # --------------------------------------------------------------------------- def step_spatial_models(df: pd.DataFrame, x_cols: List[str]) -> Dict[str, Any]: from src.spatial_models import (build_weights_matrix, estimate_spatial_lag, estimate_spatial_error, estimate_spatial_durbin, test_spatial_autocorrelation, compare_spatial_models) y_col = "log_price" if y_col not in df.columns or not x_cols: logger.warning("No log_price or no x_cols; skipping spatial models") return {} lat_col = "lat" if "lat" in df.columns else "latitude" lon_col = "long" if "long" in df.columns else "longitude" # Filter to rows with valid coords, price, and all x_cols valid_cols = [y_col, lat_col, lon_col] + [c for c in x_cols if c in df.columns] sp_df = df[valid_cols].dropna().copy() logger.info("Spatial analysis: %d rows after dropping NaN", len(sp_df)) if len(sp_df) < 50: logger.warning("Too few rows for spatial models (%d)", len(sp_df)) return {} try: w = build_weights_matrix(sp_df, k=5, lat_col=lat_col, lon_col=lon_col) logger.info("Spatial weights matrix built (k=5, n=%d)", len(sp_df)) except Exception: logger.warning("Failed to build spatial weights", exc_info=True) return {} try: moran_result = test_spatial_autocorrelation(sp_df, y_col, w) logger.info("Moran's I test: %s", moran_result) except Exception: logger.warning("Moran's I test failed", exc_info=True) results = {} sp_x_cols = [c for c in x_cols if c in sp_df.columns] for name, func in [("spatial_lag", estimate_spatial_lag), ("spatial_error", estimate_spatial_error), ("spatial_durbin", estimate_spatial_durbin)]: try: result = func(sp_df, y_col, sp_x_cols, w) results[name] = result logger.info(" %s estimated successfully", name) except Exception: logger.warning(" %s failed", name, exc_info=True) if results: try: comp = compare_spatial_models(results) comp.to_csv(config.OUTPUTS_DIR / "tables" / "spatial_comparison.csv") except Exception: logger.warning("compare_spatial_models failed", exc_info=True) return results # --------------------------------------------------------------------------- # Step 9: Robustness checks # --------------------------------------------------------------------------- def step_robustness(df: pd.DataFrame, x_cols: List[str]) -> Dict[str, Any]: from src.robustness import run_all_robustness, format_robustness_summary from src.tables import export_table y_col = "log_price" if y_col not in df.columns or not x_cols: return {} results = run_all_robustness(df, y_col, x_cols) logger.info("Robustness checks: %s", list(results.keys())) summary = format_robustness_summary(results) export_table(summary, "robustness_summary", output_dir=str(config.OUTPUTS_DIR / "tables")) return results # --------------------------------------------------------------------------- # Step 10: Generate tables # --------------------------------------------------------------------------- def step_generate_tables(df: pd.DataFrame, hedonic_results: Dict, quantile_results: Dict, ml_data: Dict, spatial_results: Dict) -> List[str]: from src.tables import (summary_statistics_table, price_by_room_type_table, correlation_matrix_table, regression_comparison_table, export_table) generated = [] out = str(config.OUTPUTS_DIR / "tables") # Table 1: Summary stats try: numeric_cols = [c for c in df.select_dtypes(include=[np.number]).columns if c not in ("lat", "long", "price_segment_code")] tbl = summary_statistics_table(df, numeric_cols) export_table(tbl, "table_01_summary_statistics", output_dir=out, caption="Summary Statistics", label="tab:summary") generated.append("summary_statistics") logger.info(" Table 1: summary statistics") except Exception: logger.warning(" Table 1 failed", exc_info=True) # Table 2: Price by property type try: tbl = price_by_room_type_table(df) export_table(tbl, "table_02_price_by_type", output_dir=out, caption="Price Distribution by Property Type", label="tab:pricetype") generated.append("price_by_type") logger.info(" Table 2: price by type") except Exception: logger.warning(" Table 2 failed", exc_info=True) # Table 3: Correlation matrix try: corr_cols = ["log_price", "bedrooms", "bathrooms", "guests_count", "amenities_count", "rating", "num_reviews"] corr_cols = [c for c in corr_cols if c in df.columns] tbl = correlation_matrix_table(df, corr_cols) export_table(tbl, "table_03_correlations", output_dir=out, caption="Correlation Matrix", label="tab:corr") generated.append("correlations") logger.info(" Table 3: correlations") except Exception: logger.warning(" Table 3 failed", exc_info=True) # Table 4-5: Regression results if hedonic_results: try: tbl = regression_comparison_table(hedonic_results) export_table(tbl, "table_04_hedonic_regressions", output_dir=out, caption="Hedonic Regression Results", label="tab:hedonic") generated.append("hedonic_regressions") logger.info(" Table 4-5: hedonic regressions") except Exception: logger.warning(" Table 4-5 failed", exc_info=True) return generated # --------------------------------------------------------------------------- # Step 11: Generate figures # --------------------------------------------------------------------------- def step_generate_figures(df: pd.DataFrame, hedonic_results: Dict, quantile_results: Dict, ml_data: Dict) -> List[str]: from src.figures import generate_all_figures fig_dir = str(config.OUTPUTS_DIR / "figures") try: result = generate_all_figures(df, results=hedonic_results, save_dir=fig_dir) generated = [] for fmt_files in result.values(): generated.extend(fmt_files) logger.info("Figures generated: %d files", len(generated)) return [str(f) for f in generated] except Exception: logger.warning("generate_all_figures failed", exc_info=True) return [] # --------------------------------------------------------------------------- # Step 12: Price decomposition # --------------------------------------------------------------------------- def step_decomposition(df: pd.DataFrame, hedonic_results: Dict, feature_groups: Dict[str, List[str]]) -> Dict[str, Any]: from src.report import explain_listing_price, decompose_all_prices, generate_decomposition_summary if not hedonic_results: logger.warning("No hedonic models for decomposition") return {} # Use the fullest model available model_key = list(hedonic_results.keys())[-1] model = hedonic_results[model_key] logger.info("Decomposing prices with model: %s", model_key) try: decomp_df = decompose_all_prices(model, df, feature_groups) summary = generate_decomposition_summary(decomp_df) decomp_df.to_csv(config.OUTPUTS_DIR / "tables" / "price_decomposition.csv", index=False) summary.to_csv(config.OUTPUTS_DIR / "tables" / "decomposition_summary.csv") # Example listing sample = explain_listing_price(model, df, 0, feature_groups) logger.info("Sample decomposition: actual=%.0f, predicted=%.2f", sample.get("actual_price", 0), sample.get("predicted_price", 0)) return {"decomp_df": decomp_df, "summary": summary, "model_used": model_key} except Exception: logger.warning("Decomposition failed", exc_info=True) return {} # --------------------------------------------------------------------------- # Feature group & column resolution # --------------------------------------------------------------------------- def resolve_feature_groups(df: pd.DataFrame) -> Dict[str, List[str]]: try: from src.feature_engineering import get_feature_groups groups = get_feature_groups() except Exception: groups = {} filtered = {} for name, cols in groups.items(): present = [c for c in cols if c in df.columns] if present: filtered[name] = present return filtered def get_x_cols(df: pd.DataFrame, feature_groups: Dict[str, List[str]]) -> List[str]: exclude = {"log_price", "price_numeric", "price_category", "price_segment", "price_segment_code", "price_per_guest", "price_per_bedroom", "name", "city", "property_type", "rating_category", "lat", "long"} x_cols = [] for cols in feature_groups.values(): for c in cols: if c not in exclude and c not in x_cols and c in df.columns: x_cols.append(c) # Also add important columns if present extras = ["bedrooms", "bathrooms", "guests_count", "amenities_count", "is_superhost", "rating", "num_reviews", "num_images", "is_guest_favorite", "pets_allowed"] for c in extras: if c in df.columns and c not in x_cols: x_cols.append(c) return x_cols # --------------------------------------------------------------------------- # Summary report # --------------------------------------------------------------------------- def print_summary(df, hedonic, quantile, ml_data, spatial, timings, tables_gen, figures_gen, decomp): sep = "=" * 72 lines = ["", sep, "HEDONIC PRICING ANALYSIS — SUMMARY REPORT", sep, ""] lines.append(f" Dataset: {len(df):,} rows x {df.shape[1]} cols") lines.append("") lines.append(" HEDONIC MODELS:") if hedonic: for k in hedonic: r2 = hedonic[k].rsquared if hasattr(hedonic[k], "rsquared") else "?" lines.append(f" {k}: R²={r2:.4f}" if isinstance(r2, float) else f" {k}") else: lines.append(" (none)") lines.append(f" QUANTILE MODELS: {len(quantile)} estimated") ml_models = ml_data.get("models", {}) if ml_data else {} lines.append(f" ML MODELS: {list(ml_models.keys()) if ml_models else '(none)'}") lines.append(f" SPATIAL MODELS: {list(spatial.keys()) if spatial else '(none)'}") lines.append("") lines.append(f" Tables: {len(tables_gen)} | Figures: {len(figures_gen)}") if decomp: lines.append(f" Decomposition: {decomp.get('model_used', '?')}") lines.append("") lines.append(" TIMING:") for step, t in timings.items(): lines.append(f" {step:<40s} {t:>7.2f}s") lines.append(f" {'TOTAL':<40s} {sum(timings.values()):>7.2f}s") lines.append(sep) report = "\n".join(lines) logger.info(report) # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def parse_args(argv=None): p = argparse.ArgumentParser(description="Airbnb hedonic pricing pipeline") p.add_argument("--data-file", type=Path, default=Path("cleaned_dataset_sample.csv")) p.add_argument("--output-dir", type=Path, default=config.OUTPUTS_DIR) p.add_argument("--skip-spatial", action="store_true") p.add_argument("--skip-nlp", action="store_true") p.add_argument("--skip-ml", action="store_true") p.add_argument("--models-only", action="store_true") p.add_argument("--figures-only", action="store_true") return p.parse_args(argv) # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def main(argv=None) -> int: args = parse_args(argv) _setup_logging(args.output_dir) timings: Dict[str, float] = {} df = pd.DataFrame() hedonic_results: Dict[str, Any] = {} quantile_results: Dict[str, Any] = {} ml_data: Dict[str, Any] = {} shap_results: Dict[str, Any] = {} spatial_results: Dict[str, Any] = {} tables_gen: List[str] = [] figures_gen: List[str] = [] decomp_info: Dict[str, Any] = {} logger.info("Pipeline started. data=%s output=%s", args.data_file, args.output_dir) logger.info("Flags: skip_spatial=%s skip_nlp=%s skip_ml=%s models_only=%s figures_only=%s", args.skip_spatial, args.skip_nlp, args.skip_ml, args.models_only, args.figures_only) try: # Step 1 with step_timer("1. Load data", timings): df = step_load_data(args.data_file) # Step 2 with step_timer("2. Clean prices", timings): df = step_clean_prices(df) # Step 3 with step_timer("3. Feature engineering", timings): df = step_engineer_features(df, skip_nlp=args.skip_nlp) feature_groups = resolve_feature_groups(df) x_cols = get_x_cols(df, feature_groups) logger.info("Feature groups: %s", {k: len(v) for k, v in feature_groups.items()}) logger.info("Total x_cols for modeling: %d", len(x_cols)) # Ensure boolean columns are numeric for c in df.columns: if df[c].dtype == bool: df[c] = df[c].astype(int) if args.figures_only: with step_timer("11. Generate figures", timings): figures_gen = step_generate_figures(df, hedonic_results, quantile_results, ml_data) print_summary(df, hedonic_results, quantile_results, ml_data, spatial_results, timings, tables_gen, figures_gen, decomp_info) return 0 # Step 4 with step_timer("4. Hedonic models", timings): hedonic_results = step_hedonic_models(df) # Step 5 with step_timer("5. Quantile models", timings): # Use structural + key cols for quantile models qx = [c for c in x_cols if c in df.columns][:15] quantile_results = step_quantile_models(df, qx) # Step 6 if not args.skip_ml: with step_timer("6. ML models", timings): ml_data = step_ml_models(df, x_cols) else: logger.info("ML models skipped") timings["6. ML models"] = 0.0 # Step 7 if not args.skip_ml and ml_data: with step_timer("7. SHAP values", timings): shap_results = step_shap_analysis(ml_data) else: timings["7. SHAP values"] = 0.0 # Step 8 if not args.skip_spatial: with step_timer("8. Spatial models", timings): sp_cols = [c for c in x_cols if c in df.columns][:10] spatial_results = step_spatial_models(df, sp_cols) else: timings["8. Spatial models"] = 0.0 # Step 9 with step_timer("9. Robustness checks", timings): rob_cols = [c for c in ["bedrooms", "bathrooms", "guests_count", "amenities_count", "is_superhost", "rating", "num_reviews", "is_entire_home"] if c in df.columns] try: step_robustness(df, rob_cols) except Exception: logger.warning("Robustness checks failed", exc_info=True) if args.models_only: print_summary(df, hedonic_results, quantile_results, ml_data, spatial_results, timings, tables_gen, figures_gen, decomp_info) return 0 # Step 10 with step_timer("10. Generate tables", timings): tables_gen = step_generate_tables(df, hedonic_results, quantile_results, ml_data, spatial_results) # Step 11 with step_timer("11. Generate figures", timings): figures_gen = step_generate_figures(df, hedonic_results, quantile_results, ml_data) # Step 12 with step_timer("12. Price decomposition", timings): decomp_info = step_decomposition(df, hedonic_results, feature_groups) # Step 13 with step_timer("13. Summary report", timings): print_summary(df, hedonic_results, quantile_results, ml_data, spatial_results, timings, tables_gen, figures_gen, decomp_info) logger.info("Pipeline finished successfully in %.2f s", sum(timings.values())) return 0 except KeyboardInterrupt: logger.warning("Interrupted by user") return 130 except Exception as exc: logger.error("Pipeline failed: %s", exc, exc_info=True) print_summary(df, hedonic_results, quantile_results, ml_data, spatial_results, timings, tables_gen, figures_gen, decomp_info) return 1 if __name__ == "__main__": sys.exit(main())