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%
27.1 KB · 684 lines python
Raw Blame History
1#!/usr/bin/env python32"""Master pipeline for Airbnb hedonic pricing analysis."""34from __future__ import annotations56import argparse7import logging8import sys9import time10import traceback11from contextlib import contextmanager12from pathlib import Path13from typing import Any, Dict, List, Optional1415import numpy as np16import pandas as pd1718PROJECT_ROOT = Path(__file__).resolve().parent.parent19if str(PROJECT_ROOT) not in sys.path:20    sys.path.insert(0, str(PROJECT_ROOT))2122from src import config2324logger = logging.getLogger("run_analysis")252627def _setup_logging(output_dir: Path) -> None:28    output_dir.mkdir(parents=True, exist_ok=True)29    root = logging.getLogger()30    root.setLevel(logging.DEBUG)31    root.handlers.clear()32    fmt = logging.Formatter(config.LOG_FORMAT, datefmt=config.LOG_DATE_FORMAT)33    ch = logging.StreamHandler(sys.stdout)34    ch.setLevel(logging.INFO)35    ch.setFormatter(fmt)36    root.addHandler(ch)37    fh = logging.FileHandler(str(output_dir / "analysis.log"), mode="w", encoding="utf-8")38    fh.setLevel(logging.DEBUG)39    fh.setFormatter(fmt)40    root.addHandler(fh)414243@contextmanager44def step_timer(step_name: str, timings: Dict[str, float]):45    logger.info("=" * 72)46    logger.info("STEP START : %s", step_name)47    logger.info("=" * 72)48    t0 = time.perf_counter()49    try:50        yield51    finally:52        elapsed = time.perf_counter() - t053        timings[step_name] = elapsed54        logger.info("STEP END   : %s  [%.2f s]", step_name, elapsed)555657# ---------------------------------------------------------------------------58# Step 1: Load data59# ---------------------------------------------------------------------------60def step_load_data(data_file: Path) -> pd.DataFrame:61    logger.info("Loading data from %s", data_file)62    df = pd.read_csv(data_file)63    logger.info("Loaded %d rows x %d cols", *df.shape)64    return df656667# ---------------------------------------------------------------------------68# Step 2: Clean prices69# ---------------------------------------------------------------------------70def step_clean_prices(df: pd.DataFrame) -> pd.DataFrame:71    from src.cleaning import run_full_cleaning, create_price_variants72    try:73        df = run_full_cleaning(df)74        logger.info("Full cleaning complete: %d rows, %d cols", *df.shape)75    except Exception:76        logger.warning("run_full_cleaning failed, applying fallback", exc_info=True)77        if "price_numeric" in df.columns:78            df = df[df["price_numeric"].notna()].copy()79            df = df[(df["price_numeric"] >= config.PRICE_FLOOR) &80                    (df["price_numeric"] <= config.PRICE_CEILING)].copy()81            df["log_price"] = np.log(df["price_numeric"])82            logger.info("Fallback cleaning: %d rows remain", len(df))83    return df848586# ---------------------------------------------------------------------------87# Step 3: Feature engineering88# ---------------------------------------------------------------------------89def step_engineer_features(df: pd.DataFrame, *, skip_nlp: bool = False) -> pd.DataFrame:90    # Ensure log_price exists91    if "log_price" not in df.columns and "price_numeric" in df.columns:92        df["log_price"] = np.log(df["price_numeric"].clip(lower=1))9394    # 3a. Core features95    try:96        from src.feature_engineering import create_all_features97        df = create_all_features(df)98        logger.info("Core features: %d cols", df.shape[1])99    except Exception:100        logger.warning("feature_engineering failed", exc_info=True)101102    # 3b. Amenity indices103    try:104        from src.amenities import create_amenity_indices105        df = create_amenity_indices(df)106        logger.info("Amenity indices added: %d cols", df.shape[1])107    except Exception:108        logger.warning("amenity indices failed", exc_info=True)109110    # 3c. Geospatial features111    try:112        from src.geospatial import compute_distance_features113        df = compute_distance_features(df)114        logger.info("Geospatial features: %d cols", df.shape[1])115    except Exception:116        logger.warning("geospatial features failed", exc_info=True)117118    # 3d. NLP features119    if not skip_nlp:120        try:121            from src.nlp_features import create_all_nlp_features122            df, price_words = create_all_nlp_features(df)123            logger.info("NLP features: %d cols", df.shape[1])124            if price_words is not None:125                price_words.to_csv(config.OUTPUTS_DIR / "tables" / "price_associated_words.csv", index=False)126                logger.info("Price-associated words saved")127        except Exception:128            logger.warning("NLP features failed", exc_info=True)129130    return df131132133# ---------------------------------------------------------------------------134# Step 4: Hedonic models135# ---------------------------------------------------------------------------136def step_hedonic_models(df: pd.DataFrame) -> Dict[str, Any]:137    from src.hedonic_models import build_model_sequence, compare_models, compute_price_premiums138    from src.tables import export_table139140    y_col = "log_price"141    if y_col not in df.columns:142        logger.warning("No log_price column; skipping hedonic models")143        return {}144145    results = build_model_sequence(df, y_col=y_col)146    logger.info("Hedonic models estimated: %s", list(results.keys()))147148    if results:149        comparison = compare_models(results)150        export_table(comparison, "model_comparison", output_dir=str(config.OUTPUTS_DIR / "tables"))151        logger.info("Model comparison table:\n%s", comparison.to_string())152153        # Price premiums from full model154        full_key = [k for k in results.keys() if "Full" in k or "9" in k]155        if full_key:156            premiums = compute_price_premiums(results[full_key[-1]])157            export_table(premiums, "price_premiums", output_dir=str(config.OUTPUTS_DIR / "tables"))158            logger.info("Price premiums:\n%s", premiums.to_string())159160    return results161162163# ---------------------------------------------------------------------------164# Step 5: Quantile models165# ---------------------------------------------------------------------------166def step_quantile_models(df: pd.DataFrame, x_cols: List[str]) -> Dict[str, Any]:167    from src.quantile_models import estimate_quantile_sequence, compare_quantile_coefficients168169    y_col = "log_price"170    if y_col not in df.columns or not x_cols:171        logger.warning("No log_price or no x_cols; skipping quantile models")172        return {}173174    results = estimate_quantile_sequence(df, y_col, x_cols)175    logger.info("Quantile models estimated: taus=%s", list(results.keys()))176177    if results:178        coef_df = compare_quantile_coefficients(results)179        coef_df.to_csv(config.OUTPUTS_DIR / "tables" / "quantile_coefficients.csv", index=False)180181    return results182183184# ---------------------------------------------------------------------------185# Step 6: ML models186# ---------------------------------------------------------------------------187def step_ml_models(df: pd.DataFrame, x_cols: List[str]) -> Dict[str, Any]:188    from src.ml_models import prepare_ml_data, train_all_models, compare_all_models189    from src.tables import export_table190191    y_col = "log_price"192    if y_col not in df.columns or not x_cols:193        logger.warning("No log_price or no x_cols; skipping ML models")194        return {}195196    X_train, X_test, y_train, y_test, feat_names = prepare_ml_data(df, y_col, x_cols)197    logger.info("ML data prepared: train=%d, test=%d, features=%d", len(X_train), len(X_test), len(feat_names))198199    models = train_all_models(X_train, y_train)200    logger.info("ML models trained: %s", list(models.keys()))201202    if models:203        comparison = compare_all_models(models, X_test, y_test)204        export_table(comparison, "ml_comparison", output_dir=str(config.OUTPUTS_DIR / "tables"))205        logger.info("ML comparison:\n%s", comparison.to_string())206207    return {208        "models": models,209        "X_train": X_train, "X_test": X_test,210        "y_train": y_train, "y_test": y_test,211        "feature_names": feat_names,212    }213214215# ---------------------------------------------------------------------------216# Step 7: SHAP values217# ---------------------------------------------------------------------------218def step_shap_analysis(ml_data: Dict[str, Any]) -> Dict[str, Any]:219    from src.shap_analysis import compute_shap_values, plot_shap_summary, get_shap_importance, plot_partial_dependence220221    if not ml_data or "models" not in ml_data:222        logger.warning("No ML models; skipping SHAP")223        return {}224225    models = ml_data["models"]226    X_test = ml_data["X_test"]227    feat_names = ml_data["feature_names"]228    fig_dir = str(config.OUTPUTS_DIR / "figures")229    results = {}230231    # Use best tree-based model for SHAP232    for model_name in ["xgboost", "lightgbm", "gradient_boosting", "random_forest"]:233        if model_name in models:234            model = models[model_name]235            try:236                shap_vals = compute_shap_values(model, X_test, feat_names)237                plot_shap_summary(shap_vals, X_test, feat_names, f"{fig_dir}/10_shap_summary")238                importance_df = get_shap_importance(shap_vals, feat_names)239                importance_df.to_csv(config.OUTPUTS_DIR / "tables" / "shap_importance.csv", index=False)240                logger.info("SHAP analysis complete for %s", model_name)241242                # Partial dependence plots243                key_features = ["bedrooms", "bathrooms", "guests_count", "distance_to_center", "rating"]244                available_features = [f for f in key_features if f in feat_names]245                if available_features:246                    feat_indices = [feat_names.index(f) for f in available_features]247                    try:248                        plot_partial_dependence(model, X_test, feat_indices, feat_names,249                                               f"{fig_dir}/11_partial_dependence")250                    except Exception:251                        logger.warning("PDP plot failed", exc_info=True)252253                results["shap_values"] = shap_vals254                results["model_name"] = model_name255                results["importance"] = importance_df256                break257            except Exception:258                logger.warning("SHAP failed for %s", model_name, exc_info=True)259260    return results261262263# ---------------------------------------------------------------------------264# Step 8: Spatial models265# ---------------------------------------------------------------------------266def step_spatial_models(df: pd.DataFrame, x_cols: List[str]) -> Dict[str, Any]:267    from src.spatial_models import (build_weights_matrix, estimate_spatial_lag,268                                    estimate_spatial_error, estimate_spatial_durbin,269                                    test_spatial_autocorrelation, compare_spatial_models)270271    y_col = "log_price"272    if y_col not in df.columns or not x_cols:273        logger.warning("No log_price or no x_cols; skipping spatial models")274        return {}275276    lat_col = "lat" if "lat" in df.columns else "latitude"277    lon_col = "long" if "long" in df.columns else "longitude"278279    # Filter to rows with valid coords, price, and all x_cols280    valid_cols = [y_col, lat_col, lon_col] + [c for c in x_cols if c in df.columns]281    sp_df = df[valid_cols].dropna().copy()282    logger.info("Spatial analysis: %d rows after dropping NaN", len(sp_df))283284    if len(sp_df) < 50:285        logger.warning("Too few rows for spatial models (%d)", len(sp_df))286        return {}287288    try:289        w = build_weights_matrix(sp_df, k=5, lat_col=lat_col, lon_col=lon_col)290        logger.info("Spatial weights matrix built (k=5, n=%d)", len(sp_df))291    except Exception:292        logger.warning("Failed to build spatial weights", exc_info=True)293        return {}294295    try:296        moran_result = test_spatial_autocorrelation(sp_df, y_col, w)297        logger.info("Moran's I test: %s", moran_result)298    except Exception:299        logger.warning("Moran's I test failed", exc_info=True)300301    results = {}302    sp_x_cols = [c for c in x_cols if c in sp_df.columns]303    for name, func in [("spatial_lag", estimate_spatial_lag),304                        ("spatial_error", estimate_spatial_error),305                        ("spatial_durbin", estimate_spatial_durbin)]:306        try:307            result = func(sp_df, y_col, sp_x_cols, w)308            results[name] = result309            logger.info("  %s estimated successfully", name)310        except Exception:311            logger.warning("  %s failed", name, exc_info=True)312313    if results:314        try:315            comp = compare_spatial_models(results)316            comp.to_csv(config.OUTPUTS_DIR / "tables" / "spatial_comparison.csv")317        except Exception:318            logger.warning("compare_spatial_models failed", exc_info=True)319320    return results321322323# ---------------------------------------------------------------------------324# Step 9: Robustness checks325# ---------------------------------------------------------------------------326def step_robustness(df: pd.DataFrame, x_cols: List[str]) -> Dict[str, Any]:327    from src.robustness import run_all_robustness, format_robustness_summary328    from src.tables import export_table329330    y_col = "log_price"331    if y_col not in df.columns or not x_cols:332        return {}333334    results = run_all_robustness(df, y_col, x_cols)335    logger.info("Robustness checks: %s", list(results.keys()))336337    summary = format_robustness_summary(results)338    export_table(summary, "robustness_summary", output_dir=str(config.OUTPUTS_DIR / "tables"))339340    return results341342343# ---------------------------------------------------------------------------344# Step 10: Generate tables345# ---------------------------------------------------------------------------346def step_generate_tables(df: pd.DataFrame, hedonic_results: Dict, quantile_results: Dict,347                         ml_data: Dict, spatial_results: Dict) -> List[str]:348    from src.tables import (summary_statistics_table, price_by_room_type_table,349                           correlation_matrix_table, regression_comparison_table,350                           export_table)351352    generated = []353    out = str(config.OUTPUTS_DIR / "tables")354355    # Table 1: Summary stats356    try:357        numeric_cols = [c for c in df.select_dtypes(include=[np.number]).columns358                        if c not in ("lat", "long", "price_segment_code")]359        tbl = summary_statistics_table(df, numeric_cols)360        export_table(tbl, "table_01_summary_statistics", output_dir=out,361                     caption="Summary Statistics", label="tab:summary")362        generated.append("summary_statistics")363        logger.info("  Table 1: summary statistics")364    except Exception:365        logger.warning("  Table 1 failed", exc_info=True)366367    # Table 2: Price by property type368    try:369        tbl = price_by_room_type_table(df)370        export_table(tbl, "table_02_price_by_type", output_dir=out,371                     caption="Price Distribution by Property Type", label="tab:pricetype")372        generated.append("price_by_type")373        logger.info("  Table 2: price by type")374    except Exception:375        logger.warning("  Table 2 failed", exc_info=True)376377    # Table 3: Correlation matrix378    try:379        corr_cols = ["log_price", "bedrooms", "bathrooms", "guests_count",380                     "amenities_count", "rating", "num_reviews"]381        corr_cols = [c for c in corr_cols if c in df.columns]382        tbl = correlation_matrix_table(df, corr_cols)383        export_table(tbl, "table_03_correlations", output_dir=out,384                     caption="Correlation Matrix", label="tab:corr")385        generated.append("correlations")386        logger.info("  Table 3: correlations")387    except Exception:388        logger.warning("  Table 3 failed", exc_info=True)389390    # Table 4-5: Regression results391    if hedonic_results:392        try:393            tbl = regression_comparison_table(hedonic_results)394            export_table(tbl, "table_04_hedonic_regressions", output_dir=out,395                         caption="Hedonic Regression Results", label="tab:hedonic")396            generated.append("hedonic_regressions")397            logger.info("  Table 4-5: hedonic regressions")398        except Exception:399            logger.warning("  Table 4-5 failed", exc_info=True)400401    return generated402403404# ---------------------------------------------------------------------------405# Step 11: Generate figures406# ---------------------------------------------------------------------------407def step_generate_figures(df: pd.DataFrame, hedonic_results: Dict,408                          quantile_results: Dict, ml_data: Dict) -> List[str]:409    from src.figures import generate_all_figures410411    fig_dir = str(config.OUTPUTS_DIR / "figures")412    try:413        result = generate_all_figures(df, results=hedonic_results, save_dir=fig_dir)414        generated = []415        for fmt_files in result.values():416            generated.extend(fmt_files)417        logger.info("Figures generated: %d files", len(generated))418        return [str(f) for f in generated]419    except Exception:420        logger.warning("generate_all_figures failed", exc_info=True)421        return []422423424# ---------------------------------------------------------------------------425# Step 12: Price decomposition426# ---------------------------------------------------------------------------427def step_decomposition(df: pd.DataFrame, hedonic_results: Dict,428                       feature_groups: Dict[str, List[str]]) -> Dict[str, Any]:429    from src.report import explain_listing_price, decompose_all_prices, generate_decomposition_summary430431    if not hedonic_results:432        logger.warning("No hedonic models for decomposition")433        return {}434435    # Use the fullest model available436    model_key = list(hedonic_results.keys())[-1]437    model = hedonic_results[model_key]438    logger.info("Decomposing prices with model: %s", model_key)439440    try:441        decomp_df = decompose_all_prices(model, df, feature_groups)442        summary = generate_decomposition_summary(decomp_df)443        decomp_df.to_csv(config.OUTPUTS_DIR / "tables" / "price_decomposition.csv", index=False)444        summary.to_csv(config.OUTPUTS_DIR / "tables" / "decomposition_summary.csv")445446        # Example listing447        sample = explain_listing_price(model, df, 0, feature_groups)448        logger.info("Sample decomposition: actual=%.0f, predicted=%.2f",449                     sample.get("actual_price", 0), sample.get("predicted_price", 0))450451        return {"decomp_df": decomp_df, "summary": summary, "model_used": model_key}452    except Exception:453        logger.warning("Decomposition failed", exc_info=True)454        return {}455456457# ---------------------------------------------------------------------------458# Feature group & column resolution459# ---------------------------------------------------------------------------460def resolve_feature_groups(df: pd.DataFrame) -> Dict[str, List[str]]:461    try:462        from src.feature_engineering import get_feature_groups463        groups = get_feature_groups()464    except Exception:465        groups = {}466467    filtered = {}468    for name, cols in groups.items():469        present = [c for c in cols if c in df.columns]470        if present:471            filtered[name] = present472    return filtered473474475def get_x_cols(df: pd.DataFrame, feature_groups: Dict[str, List[str]]) -> List[str]:476    exclude = {"log_price", "price_numeric", "price_category", "price_segment",477               "price_segment_code", "price_per_guest", "price_per_bedroom",478               "name", "city", "property_type", "rating_category", "lat", "long"}479    x_cols = []480    for cols in feature_groups.values():481        for c in cols:482            if c not in exclude and c not in x_cols and c in df.columns:483                x_cols.append(c)484485    # Also add important columns if present486    extras = ["bedrooms", "bathrooms", "guests_count", "amenities_count",487              "is_superhost", "rating", "num_reviews", "num_images",488              "is_guest_favorite", "pets_allowed"]489    for c in extras:490        if c in df.columns and c not in x_cols:491            x_cols.append(c)492493    return x_cols494495496# ---------------------------------------------------------------------------497# Summary report498# ---------------------------------------------------------------------------499def print_summary(df, hedonic, quantile, ml_data, spatial, timings,500                  tables_gen, figures_gen, decomp):501    sep = "=" * 72502    lines = ["", sep, "HEDONIC PRICING ANALYSIS — SUMMARY REPORT", sep, ""]503    lines.append(f"  Dataset: {len(df):,} rows x {df.shape[1]} cols")504    lines.append("")505506    lines.append("  HEDONIC MODELS:")507    if hedonic:508        for k in hedonic:509            r2 = hedonic[k].rsquared if hasattr(hedonic[k], "rsquared") else "?"510            lines.append(f"    {k}: R²={r2:.4f}" if isinstance(r2, float) else f"    {k}")511    else:512        lines.append("    (none)")513514    lines.append(f"  QUANTILE MODELS: {len(quantile)} estimated")515516    ml_models = ml_data.get("models", {}) if ml_data else {}517    lines.append(f"  ML MODELS: {list(ml_models.keys()) if ml_models else '(none)'}")518    lines.append(f"  SPATIAL MODELS: {list(spatial.keys()) if spatial else '(none)'}")519    lines.append("")520    lines.append(f"  Tables: {len(tables_gen)} | Figures: {len(figures_gen)}")521522    if decomp:523        lines.append(f"  Decomposition: {decomp.get('model_used', '?')}")524525    lines.append("")526    lines.append("  TIMING:")527    for step, t in timings.items():528        lines.append(f"    {step:<40s} {t:>7.2f}s")529    lines.append(f"    {'TOTAL':<40s} {sum(timings.values()):>7.2f}s")530    lines.append(sep)531532    report = "\n".join(lines)533    logger.info(report)534535536# ---------------------------------------------------------------------------537# CLI538# ---------------------------------------------------------------------------539def parse_args(argv=None):540    p = argparse.ArgumentParser(description="Airbnb hedonic pricing pipeline")541    p.add_argument("--data-file", type=Path, default=Path("cleaned_dataset_sample.csv"))542    p.add_argument("--output-dir", type=Path, default=config.OUTPUTS_DIR)543    p.add_argument("--skip-spatial", action="store_true")544    p.add_argument("--skip-nlp", action="store_true")545    p.add_argument("--skip-ml", action="store_true")546    p.add_argument("--models-only", action="store_true")547    p.add_argument("--figures-only", action="store_true")548    return p.parse_args(argv)549550551# ---------------------------------------------------------------------------552# Main553# ---------------------------------------------------------------------------554def main(argv=None) -> int:555    args = parse_args(argv)556    _setup_logging(args.output_dir)557558    timings: Dict[str, float] = {}559    df = pd.DataFrame()560    hedonic_results: Dict[str, Any] = {}561    quantile_results: Dict[str, Any] = {}562    ml_data: Dict[str, Any] = {}563    shap_results: Dict[str, Any] = {}564    spatial_results: Dict[str, Any] = {}565    tables_gen: List[str] = []566    figures_gen: List[str] = []567    decomp_info: Dict[str, Any] = {}568569    logger.info("Pipeline started. data=%s output=%s", args.data_file, args.output_dir)570    logger.info("Flags: skip_spatial=%s skip_nlp=%s skip_ml=%s models_only=%s figures_only=%s",571                args.skip_spatial, args.skip_nlp, args.skip_ml, args.models_only, args.figures_only)572573    try:574        # Step 1575        with step_timer("1. Load data", timings):576            df = step_load_data(args.data_file)577578        # Step 2579        with step_timer("2. Clean prices", timings):580            df = step_clean_prices(df)581582        # Step 3583        with step_timer("3. Feature engineering", timings):584            df = step_engineer_features(df, skip_nlp=args.skip_nlp)585586        feature_groups = resolve_feature_groups(df)587        x_cols = get_x_cols(df, feature_groups)588        logger.info("Feature groups: %s", {k: len(v) for k, v in feature_groups.items()})589        logger.info("Total x_cols for modeling: %d", len(x_cols))590591        # Ensure boolean columns are numeric592        for c in df.columns:593            if df[c].dtype == bool:594                df[c] = df[c].astype(int)595596        if args.figures_only:597            with step_timer("11. Generate figures", timings):598                figures_gen = step_generate_figures(df, hedonic_results, quantile_results, ml_data)599            print_summary(df, hedonic_results, quantile_results, ml_data,600                          spatial_results, timings, tables_gen, figures_gen, decomp_info)601            return 0602603        # Step 4604        with step_timer("4. Hedonic models", timings):605            hedonic_results = step_hedonic_models(df)606607        # Step 5608        with step_timer("5. Quantile models", timings):609            # Use structural + key cols for quantile models610            qx = [c for c in x_cols if c in df.columns][:15]611            quantile_results = step_quantile_models(df, qx)612613        # Step 6614        if not args.skip_ml:615            with step_timer("6. ML models", timings):616                ml_data = step_ml_models(df, x_cols)617        else:618            logger.info("ML models skipped")619            timings["6. ML models"] = 0.0620621        # Step 7622        if not args.skip_ml and ml_data:623            with step_timer("7. SHAP values", timings):624                shap_results = step_shap_analysis(ml_data)625        else:626            timings["7. SHAP values"] = 0.0627628        # Step 8629        if not args.skip_spatial:630            with step_timer("8. Spatial models", timings):631                sp_cols = [c for c in x_cols if c in df.columns][:10]632                spatial_results = step_spatial_models(df, sp_cols)633        else:634            timings["8. Spatial models"] = 0.0635636        # Step 9637        with step_timer("9. Robustness checks", timings):638            rob_cols = [c for c in ["bedrooms", "bathrooms", "guests_count",639                                     "amenities_count", "is_superhost", "rating",640                                     "num_reviews", "is_entire_home"] if c in df.columns]641            try:642                step_robustness(df, rob_cols)643            except Exception:644                logger.warning("Robustness checks failed", exc_info=True)645646        if args.models_only:647            print_summary(df, hedonic_results, quantile_results, ml_data,648                          spatial_results, timings, tables_gen, figures_gen, decomp_info)649            return 0650651        # Step 10652        with step_timer("10. Generate tables", timings):653            tables_gen = step_generate_tables(df, hedonic_results, quantile_results,654                                              ml_data, spatial_results)655656        # Step 11657        with step_timer("11. Generate figures", timings):658            figures_gen = step_generate_figures(df, hedonic_results, quantile_results, ml_data)659660        # Step 12661        with step_timer("12. Price decomposition", timings):662            decomp_info = step_decomposition(df, hedonic_results, feature_groups)663664        # Step 13665        with step_timer("13. Summary report", timings):666            print_summary(df, hedonic_results, quantile_results, ml_data,667                          spatial_results, timings, tables_gen, figures_gen, decomp_info)668669        logger.info("Pipeline finished successfully in %.2f s", sum(timings.values()))670        return 0671672    except KeyboardInterrupt:673        logger.warning("Interrupted by user")674        return 130675    except Exception as exc:676        logger.error("Pipeline failed: %s", exc, exc_info=True)677        print_summary(df, hedonic_results, quantile_results, ml_data,678                      spatial_results, timings, tables_gen, figures_gen, decomp_info)679        return 1680681682if __name__ == "__main__":683    sys.exit(main())684