#!/usr/bin/env python3
"""
=============================================================================
RFX-Fuse Bike Sharing Dataset Demo: Time Series Regression
=============================================================================

USE CASE 3: Time Series Regression
Traditional Approach: XGBoost + SHAP + Isolation Forest + FAISS (4 tools)
RFX-Fuse Approach: 1 RFX-Fuse model

Dataset: UCI Bike Sharing Dataset
- Hourly bike rental counts with seasonality features
- Split: 3/4 of first year (2011) for training, last 1/4 of 2011 for testing
- Target: Hourly bike rentals (cnt)

This demonstrates RFX-Fuse's capabilities on time series regression:
- Point predictions with OOB error (comparable to XGBoost)
- Overall and local variable importance (comparable to SHAP)
- Overall and local proximity importance (unique to RFX-Fuse)
- Outlier detection (complementary to Isolation Forest - marginal vs. manifold)
- Similarity search (comparable to FAISS)

Comparisons included:
- RFX-Fuse vs XGBoost: Prediction accuracy, feature importance
- RFX-Fuse vs Isolation Forest: Outlier detection
- RFX-Fuse vs FAISS: Similarity search
- RFX-Fuse vs SHAP: Local importance explanations

Key insight: 1 RFX-Fuse model provides capabilities comparable to 4 separate
tools, all natively from a single set of trees grown once.

=============================================================================
"""

import os
import sys
import time
import numpy as np
import pandas as pd
from pathlib import Path

# Use non-interactive backend for saving plots without display
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from matplotlib.gridspec import GridSpec
import matplotlib.dates as mdates

# Setup paths relative to this file
SCRIPT_DIR = Path(__file__).parent.resolve()
PROJECT_ROOT = SCRIPT_DIR.parent.parent
sys.path.insert(0, str(PROJECT_ROOT / 'python'))
import RFXFuse as rfx

# Optional imports for comparisons
try:
    import xgboost as xgb
    HAS_XGBOOST = True
except ImportError:
    HAS_XGBOOST = False
    print("WARNING:XGBoost not available - skipping XGBoost comparisons")

try:
    from sklearn.ensemble import IsolationForest
    HAS_ISOLATION_FOREST = True
except ImportError:
    HAS_ISOLATION_FOREST = False
    print("WARNING:Isolation Forest not available - skipping Isolation Forest comparisons")

try:
    import faiss
    HAS_FAISS = True
except ImportError:
    HAS_FAISS = False
    print("WARNING:FAISS not available - skipping FAISS comparisons")

try:
    import shap
    HAS_SHAP = True
except ImportError:
    HAS_SHAP = False
    print("WARNING:SHAP not available - skipping SHAP comparisons")

# ============================================================================
# CONFIGURATION
# ============================================================================
N_TREES = 1000  # RF doesn't overfit with more trees
TOP_K_SIMILAR = 10
USE_GPU = True
GPU_BATCH_SIZE = 50

# Model save/load configuration
LOAD_MODELS = '--load' in sys.argv or '-l' in sys.argv
FORCE_RETRAIN = '--retrain' in sys.argv or '-r' in sys.argv

# Model save directory (same folder as script)
MODEL_DIR = SCRIPT_DIR
MODEL_DIR.mkdir(parents=True, exist_ok=True)

# ============================================================================
# LOAD UCI BIKE SHARING DATASET
# ============================================================================
def load_bike_sharing_data():
    """Load UCI Bike Sharing Dataset (hourly)."""
    
    cache_path = Path(__file__).parent.parent / "data" / "bike" / "bike_sharing.csv"
    
    if cache_path.exists():
        print("Loading cached Bike Sharing data...")
        df = pd.read_csv(cache_path)
    else:
        print("Downloading UCI Bike Sharing Dataset...")
        url = "https://archive.ics.uci.edu/ml/machine-learning-databases/00275/Bike-Sharing-Dataset.zip"
        
        import urllib.request
        import zipfile
        import io
        
        cache_path.parent.mkdir(parents=True, exist_ok=True)
        
        with urllib.request.urlopen(url) as response:
            with zipfile.ZipFile(io.BytesIO(response.read())) as z:
                with z.open('hour.csv') as f:
                    df = pd.read_csv(f)
        
        df.to_csv(cache_path, index=False)
        print(f"Cached to {cache_path}")
    
    # Feature engineering for time series
    feature_cols = [
        'season',      # 1-4 (spring, summer, fall, winter)
        'yr',          # 0-1 (2011, 2012)
        'mnth',        # 1-12
        'hr',          # 0-23
        'holiday',     # 0-1
        'weekday',     # 0-6
        'workingday',  # 0-1
        'weathersit',  # 1-4 (clear, mist, light rain, heavy rain)
        'temp',        # Normalized temperature
        'atemp',       # Normalized feeling temperature
        'hum',         # Normalized humidity
        'windspeed',   # Normalized wind speed
    ]
    
    X = df[feature_cols].values.astype(np.float32)
    y = df['cnt'].values.astype(np.float32)  # Total bike rentals
    
    feature_names = feature_cols
    
    # Create datetime index for plotting
    df['datetime'] = pd.to_datetime(df['dteday']) + pd.to_timedelta(df['hr'], unit='h')
    timestamps = df['datetime'].values
    
    return X, y, feature_names, timestamps, df


# ============================================================================
# SPLIT DATA: 2011 Half-Half split
# ============================================================================
def split_bike_data_2011_halfhalf(X, y, timestamps, df):
    """
    Split 2011 data in half: first half for training, second half for testing.
    This allows testing on same-year data to see model generalization.
    
    Returns:
        X_train, y_train, X_test, y_test, train_timestamps, test_timestamps
    """
    # Filter for 2011 only (yr == 0)
    mask_2011 = df['yr'] == 0
    X_2011 = X[mask_2011]
    y_2011 = y[mask_2011]
    timestamps_2011 = timestamps[mask_2011]
    df_2011 = df[mask_2011].reset_index(drop=True)
    
    # Split at midpoint
    n_samples = len(X_2011)
    split_idx = n_samples // 2
    
    # Train: First half of 2011
    X_train = X_2011[:split_idx]
    y_train = y_2011[:split_idx]
    train_timestamps = timestamps_2011[:split_idx]
    train_df = df_2011[:split_idx].reset_index(drop=True)
    
    # Test: Second half of 2011
    X_test = X_2011[split_idx:]
    y_test = y_2011[split_idx:]
    test_timestamps = timestamps_2011[split_idx:]
    test_df = df_2011[split_idx:].reset_index(drop=True)
    
    print(f"\n2011 Half-Half Time Series Split:")
    print(f"\nTrain Set (First Half of 2011): {len(X_train):,} samples")
    print(f"  Date range: {train_df['dteday'].min()} to {train_df['dteday'].max()}")
    print(f"  Target: mean={y_train.mean():.1f}, min={y_train.min():.0f}, max={y_train.max():.0f}")
    
    print(f"\nTest Set (Second Half of 2011): {len(X_test):,} samples")
    print(f"  Date range: {test_df['dteday'].min()} to {test_df['dteday'].max()}")
    print(f"  Target: mean={y_test.mean():.1f}, min={y_test.min():.0f}, max={y_test.max():.0f}")
    
    return X_train, y_train, X_test, y_test, train_timestamps, test_timestamps, train_df, test_df


# ============================================================================
# SPLIT DATA: Chronological split - 2011 Jan-Aug train, 2011 Sep-Dec test
# ============================================================================
def split_bike_data_chronological(X, y, timestamps, df):
    """
    Chronological split within 2011: Jan-Aug for training, Sep-Dec for testing.
    This is the proper way to split time series data.

    Returns:
        X_train, y_train, X_test, y_test, train_timestamps, test_timestamps
    """
    # Filter for 2011 only (yr == 0)
    mask_2011 = df['yr'] == 0
    X_2011 = X[mask_2011]
    y_2011 = y[mask_2011]
    timestamps_2011 = timestamps[mask_2011]
    df_2011 = df[mask_2011].reset_index(drop=True)

    # Train: 2011-01-01 to 2011-08-31 (months 1-8)
    train_mask = df_2011['mnth'] <= 8
    X_train = X_2011[train_mask]
    y_train = y_2011[train_mask]
    train_timestamps = timestamps_2011[train_mask]
    train_df = df_2011[train_mask].reset_index(drop=True)

    # Test: 2011-09-01 to 2011-12-31 (months 9-12)
    test_mask = df_2011['mnth'] >= 9
    X_test = X_2011[test_mask]
    y_test = y_2011[test_mask]
    test_timestamps = timestamps_2011[test_mask]
    test_df = df_2011[test_mask].reset_index(drop=True)

    print(f"\nChronological Time Series Split (2011 Only):")
    print(f"\nTrain Set (Jan-Aug 2011): {len(X_train):,} samples")
    print(f"  Date range: {train_df['dteday'].min()} to {train_df['dteday'].max()}")
    print(f"  Target: mean={y_train.mean():.1f}, min={y_train.min():.0f}, max={y_train.max():.0f}")

    print(f"\nTest Set (Sep-Dec 2011): {len(X_test):,} samples")
    print(f"  Date range: {test_df['dteday'].min()} to {test_df['dteday'].max()}")
    print(f"  Target: mean={y_test.mean():.1f}, min={y_test.min():.0f}, max={y_test.max():.0f}")

    return X_train, y_train, X_test, y_test, train_timestamps, test_timestamps, train_df, test_df


# ============================================================================
# LEGACY: SPLIT DATA - 3/4 of first year (2011) for train, last 1/4 for test
# ============================================================================
def split_bike_data_2011(X, y, timestamps, df):
    """
    Split data: 3/4 of 2011 for training, last 1/4 of 2011 for testing.
    
    Returns:
        X_train, y_train, X_test, y_test, train_timestamps, test_timestamps
    """
    # Filter for 2011 only (yr == 0)
    mask_2011 = df['yr'] == 0
    X_2011 = X[mask_2011]
    y_2011 = y[mask_2011]
    timestamps_2011 = timestamps[mask_2011]
    df_2011 = df[mask_2011].reset_index(drop=True)
    
    print(f"\n2011 Data: {len(X_2011):,} samples")
    print(f"  Date range: {df_2011['dteday'].min()} to {df_2011['dteday'].max()}")
    
    # Split at 3/4 of the way through 2011
    split_idx = int(len(X_2011) * 0.75)
    
    X_train = X_2011[:split_idx]
    y_train = y_2011[:split_idx]
    train_timestamps = timestamps_2011[:split_idx]
    train_df = df_2011.iloc[:split_idx]
    
    X_test = X_2011[split_idx:]
    y_test = y_2011[split_idx:]
    test_timestamps = timestamps_2011[split_idx:]
    test_df = df_2011.iloc[split_idx:]
    
    print(f"\nTrain Set: {len(X_train):,} samples")
    print(f"  Date range: {train_df['dteday'].min()} to {train_df['dteday'].max()}")
    print(f"  Target: mean={y_train.mean():.1f}, min={y_train.min():.0f}, max={y_train.max():.0f}")
    
    print(f"\nTest Set: {len(X_test):,} samples")
    print(f"  Date range: {test_df['dteday'].min()} to {test_df['dteday'].max()}")
    print(f"  Target: mean={y_test.mean():.1f}, min={y_test.min():.0f}, max={y_test.max():.0f}")
    
    return X_train, y_train, X_test, y_test, train_timestamps, test_timestamps, train_df, test_df


# ============================================================================
# MAIN DEMO
# ============================================================================
def main():
    print("=" * 75)
    print("RFX-FUSE BIKE SHARING DATASET DEMO")
    print("Split: First Half of 2011 (train) → Second Half of 2011 (test)")
    print("=" * 75)
    
    # Load data
    X, y, feature_names, timestamps, df = load_bike_sharing_data()
    print(f"\nFull Dataset: {X.shape[0]:,} samples, {X.shape[1]} features")
    print(f"Features: {feature_names}")
    
    # Use core time series features for fair RFX vs XGBoost comparison
    # Excluding yr (train/test leak) and redundant features
    selected_features = ['hr', 'season', 'mnth', 'temp']
    selected_indices = [feature_names.index(feat) for feat in selected_features]
    X = X[:, selected_indices]
    feature_names = selected_features
    print(f"\nUsing {len(selected_features)} core features for fair comparison: {feature_names}")
    
    # Split data using chronological split (2011 train, 2012 test)
    X_train, y_train, X_test, y_test, train_timestamps, test_timestamps, train_df, test_df = \
        split_bike_data_chronological(X, y, timestamps, df)
    
    # =========================================================================
    # TRAIN RFX REGRESSOR
    # =========================================================================
    print(f"\n{'='*75}")
    print("TRAINING RFX-FUSE REGRESSOR")
    print("=" * 75)

    model_path = MODEL_DIR / "regressor.rfx"

    if LOAD_MODELS and not FORCE_RETRAIN and model_path.exists():
        print(f"\n[LOAD]Loading saved model from {model_path.name}...")
        model = rfx.load(str(model_path))
        train_time = 0.0  # No training time if loaded
        print("Model loaded successfully")
    else:
        t0 = time.time()
        model = rfx.RandomForestRegressor(
            ntree=N_TREES,
            use_gpu=USE_GPU,
            batch_size=GPU_BATCH_SIZE,
            compute_proximity_importance=True,
            compute_local_importance=True,
            compute_leaf_assignments=True,
            iseed=42,
        )
        model.fit(X_train, y_train)
        train_time = time.time() - t0

        # Save model
        print(f"\n[SAVE]Saving model to {model_path.name}...")
        model.save(str(model_path))
        print("Model saved successfully")

    print(f"\nTraining: {train_time:.1f}s ({N_TREES} trees)")
    print(f"OOB MSE: {model.get_oob_error():.4f}")
    print(f"OOB RMSE: {np.sqrt(model.get_oob_error()):.2f}")
    
    # =========================================================================
    # OVERALL VARIABLE IMPORTANCE
    # =========================================================================
    try:
        # Try calling as a method
        overall_var = model.feature_importances_()
    except TypeError:
        try:
            # Try as a property
            overall_var = model.feature_importances_
            if callable(overall_var):
                overall_var = overall_var()
        except (AttributeError, TypeError):
            # Try alternative method name
            try:
                overall_var = model.get_feature_importances()
            except (AttributeError, TypeError):
                print("WARNING:Feature importances not available")
                overall_var = np.ones(len(feature_names))
    
    overall_var = np.array(overall_var)
    sorted_var = np.argsort(overall_var)[::-1]
    
    print(f"\n{'='*75}")
    print("OVERALL VARIABLE IMPORTANCE")
    print("=" * 75)
    print("'What features predict bike rentals globally?'")
    for i in range(len(feature_names)):
        idx = sorted_var[i]
        print(f"  {i+1}. {feature_names[idx]:15s} {overall_var[idx]:.4f}")
    
    # =========================================================================
    # OVERALL PROXIMITY IMPORTANCE
    # =========================================================================
    # Try to get proximity importance; skip if not available
    try:
        overall_prox = model.get_proximity_importance()
        overall_prox_sum = overall_prox.sum(axis=0)
        has_proximity = True
    except (AttributeError, NotImplementedError):
        print("WARNING:Proximity importance not available in this RFX version")
        overall_prox_sum = np.zeros(len(feature_names))
        has_proximity = False
    
    if has_proximity and overall_prox_sum.sum() > 0:
        sorted_prox = np.argsort(overall_prox_sum)[::-1]
        print(f"\n{'='*75}")
        print("OVERALL PROXIMITY IMPORTANCE")
        print("=" * 75)
        print("'What features cluster time periods globally?'")
        for i in range(len(feature_names)):
            idx = sorted_prox[i]
            print(f"  {i+1}. {feature_names[idx]:15s} {overall_prox_sum[idx]:.4f}")
    else:
        sorted_prox = sorted_var  # Fall back to variable importance ranking
    
    # =========================================================================
    # TEST SET PREDICTIONS
    # =========================================================================
    print(f"\n{'='*75}")
    print("TEST SET PREDICTIONS")
    print("=" * 75)
    
    t0 = time.time()
    y_pred = model.predict(X_test)
    pred_time = time.time() - t0
    
    mse = np.mean((y_test - y_pred) ** 2)
    rmse = np.sqrt(mse)
    mae = np.mean(np.abs(y_test - y_pred))
    
    print(f"\nPrediction time: {pred_time:.3f}s for {len(X_test):,} samples")
    print(f"Test MSE:  {mse:.2f}")
    print(f"Test RMSE: {rmse:.2f}")
    print(f"Test MAE:  {mae:.2f}")
    
    # Correlation
    corr = np.corrcoef(y_test, y_pred)[0, 1]
    print(f"Correlation: {corr:.4f}")
    
    # =========================================================================
    # TEST SAMPLE ANALYSIS
    # =========================================================================
    # Select a few interesting test samples
    # 1. High rental period (rush hour)
    high_idx = np.argmax(y_test)
    # 2. Low rental period (off-peak)
    low_idx = np.argmin(y_test)
    # 3. Median rental period
    median_idx = np.argsort(y_test)[len(y_test) // 2]
    
    test_samples = [
        (high_idx, "High Rentals (Rush Hour)"),
        (median_idx, "Median Rentals"),
        (low_idx, "Low Rentals (Off-Peak)")
    ]
    
    print(f"\n{'='*75}")
    print("TEST SAMPLE ANALYSIS")
    print("=" * 75)
    
    for sample_idx, description in test_samples:
        actual = y_test[sample_idx]
        predicted = y_pred[sample_idx]
        
        print(f"\n  {description} (Test idx={sample_idx})")
        print(f"    Actual: {actual:.0f} rentals, Predicted: {predicted:.1f} rentals")
        print(f"    Error: {abs(actual - predicted):.1f} rentals")
        print(f"    Key features (by overall importance):")
        for i, feat_idx in enumerate(sorted_var[:5]):
            feat_val = X_test[sample_idx, feat_idx]
            print(f"      {i+1}. {feature_names[feat_idx]:15s} = {feat_val:.3f}")
    
    # =========================================================================
    # SIMILARITY SEARCH
    # =========================================================================
    print(f"\n{'='*75}")
    print("SIMILARITY SEARCH")
    print("=" * 75)
    
    # Find similar training samples for a test sample
    # Note: get_top_k_similar works on training samples, so we need to find
    # the most similar training sample to our test query first
    query_test_idx = high_idx  # Use the high rental period as query
    query_features = X_test[query_test_idx:query_test_idx+1]
    
    # Predict on query to get leaf assignments, then find similar training samples
    # For now, we'll find the training sample most similar to our query
    # by computing predictions and finding similar feature patterns
    
    print(f"\n  Query: Test sample {query_test_idx} (Actual: {y_test[query_test_idx]:.0f} rentals)")
    print(f"  Finding similar training samples...")
    
    # Find training samples with similar feature values (simple approach)
    # In practice, you'd use the model's proximity computation
    # For demo, we'll show a few training samples with similar key features
    query_hr = X_test[query_test_idx, feature_names.index('hr')]
    query_temp = X_test[query_test_idx, feature_names.index('temp')]
    
    # Find training samples with similar hour and temperature
    hr_diff = np.abs(X_train[:, feature_names.index('hr')] - query_hr)
    temp_diff = np.abs(X_train[:, feature_names.index('temp')] - query_temp)
    combined_diff = hr_diff + temp_diff * 10  # Weight temp more
    similar_train_indices = np.argsort(combined_diff)[:TOP_K_SIMILAR]
    similarity_scores = 1.0 / (1.0 + combined_diff[similar_train_indices])
    
    print(f"  Top-{TOP_K_SIMILAR} similar training samples (by feature similarity):")
    for i, train_idx in enumerate(similar_train_indices):
        actual_sim = y_train[train_idx]
        pred_sim = model.predict(X_train[train_idx:train_idx+1])[0]
        similarity = similarity_scores[i]
        print(f"    {i+1}. Train idx {train_idx:5d}: similarity={similarity:.4f}, "
              f"Actual={actual_sim:.0f}, Pred={pred_sim:.1f}")
    
    # Show query features
    print(f"\n  Query features:")
    for feat_idx in sorted_var[:5]:
        feat_val = X_test[query_test_idx, feat_idx]
        print(f"    {feature_names[feat_idx]:15s} = {feat_val:.3f}")
    
    # =========================================================================
    # OUTLIER DETECTION
    # =========================================================================
    print(f"\n{'='*75}")
    print("OUTLIER DETECTION")
    print("=" * 75)
    
    print("  Computing outlier scores (full mode)...")
    t0_outlier = time.time()
    try:
        # Use full mode for accurate outlier detection (not greedy)
        outlier_scores = model.compute_outlier_scores()
    except (AttributeError, TypeError):
        try:
            # Try greedy mode as fallback
            outlier_scores = model.compute_outlier_scores(mode='greedy')
        except (AttributeError, TypeError):
            # Fallback: use simple proximity-based outlier detection
            print("  WARNING:compute_outlier_scores not available, using fallback")
            outlier_scores = np.ones(len(y_train))
    outlier_time = time.time() - t0_outlier
    
    print(f"  Computation time: {outlier_time:.2f}s")
    
    # Top outliers
    try:
        top_result = model.compute_outliers(k=10)
        if isinstance(top_result, tuple):
            top_outliers, top_scores = top_result
        else:
            top_outliers = top_result
            top_scores = outlier_scores[top_outliers]
    except (AttributeError, TypeError):
        # Fallback
        top_indices = np.argsort(outlier_scores)[-10:]
        top_outliers = top_indices
        top_scores = outlier_scores[top_outliers]
    print(f"\n  Top 5 Outliers:")
    for i in range(min(5, len(top_outliers))):
        idx = top_outliers[i]
        score = top_scores[i] if i < len(top_scores) else outlier_scores[idx]
        if idx < len(y_train):
            actual = y_train[idx]
            pred = model.predict(X_train[idx:idx+1])[0]
            print(f"    {i+1}. Train idx {idx:5d}: score={score:.1f}, "
                  f"Actual={actual:.0f}, Pred={pred:.1f}, Error={abs(actual-pred):.1f}")
    
    outlier_threshold = np.percentile(outlier_scores, 90)
    n_outliers = np.sum(outlier_scores > outlier_threshold)
    print(f"  Total outliers (top 10%): {n_outliers} ({100*n_outliers/len(y_train):.1f}%)")
    
    # =========================================================================
    # COMPARISONS: RFX vs Traditional Tools (XGBoost + Isolation Forest + FAISS + SHAP)
    # =========================================================================
    print(f"\n{'='*75}")
    print("COMPARISONS: RFX-Fuse vs Traditional Tools")
    print("=" * 75)
    print("Traditional Approach: XGBoost + Isolation Forest + FAISS + SHAP + custom code")
    print("RFX-Fuse Approach: Single model object providing all capabilities")
    
    # Comparison 1: XGBoost (Prediction)
    if HAS_XGBOOST:
        print(f"\nCOMPARISON 1: RFX-Fuse vs XGBoost (Prediction)")
        print(f"   Training XGBoost Regressor...")
        t0 = time.time()

        # Create TEMPORAL validation split from training data (NOT test data) for early stopping
        # Use last 20% of training period (e.g., Aug 2011) as validation - preserves time series integrity
        val_split_idx = int(len(X_train) * 0.8)
        X_train_xgb = X_train[:val_split_idx]
        y_train_xgb = y_train[:val_split_idx]
        X_val_xgb = X_train[val_split_idx:]
        y_val_xgb = y_train[val_split_idx:]

        xgb_model = xgb.XGBRegressor(
            n_estimators=N_TREES,
            max_depth=10,
            learning_rate=0.1,
            random_state=42,
            early_stopping_rounds=50,
            eval_metric='rmse'
        )
        # Train on training subset, early-stop on validation (NOT test data)
        xgb_model.fit(X_train_xgb, y_train_xgb, eval_set=[(X_val_xgb, y_val_xgb)], verbose=False)
        xgb_train_time = time.time() - t0
        
        xgb_pred = xgb_model.predict(X_test)
        xgb_mse = np.mean((y_test - xgb_pred) ** 2)
        xgb_rmse = np.sqrt(xgb_mse)
        xgb_mae = np.mean(np.abs(y_test - xgb_pred))
        
        print(f"   {'Metric':<15s} {'RFX-Fuse':>15s} {'XGBoost':>15s}")
        print(f"   {'-'*15} {'-'*15} {'-'*15}")
        print(f"   {'Train Time':<15s} {train_time:>14.2f}s {xgb_train_time:>14.2f}s")
        print(f"   {'Test MSE':<15s} {mse:>15.2f} {xgb_mse:>15.2f}")
        print(f"   {'Test RMSE':<15s} {rmse:>15.2f} {xgb_rmse:>15.2f}")
        print(f"   {'Test MAE':<15s} {mae:>15.2f} {xgb_mae:>15.2f}")
        print(f"   {'OOB Error':<15s} {model.get_oob_error():>15.2f} {'N/A (no OOB)':>15s}")
        
        # Feature importance comparison
        xgb_importance = xgb_model.feature_importances_
        xgb_importance_norm = xgb_importance / xgb_importance.sum()
        rfx_importance_norm = overall_var / overall_var.sum()
        
        print(f"\n   Top Feature Importance Comparison:")
        print(f"   {'Rank':<6s} {'Feature':<15s} {'RFX-Fuse':>12s} {'XGBoost':>12s}")
        print(f"   {'-'*6} {'-'*15} {'-'*12} {'-'*12}")
        rfx_rank = np.argsort(overall_var)[::-1]
        xgb_rank = np.argsort(xgb_importance)[::-1]
        for i in range(min(5, len(feature_names))):
            rfx_idx = rfx_rank[i]
            xgb_idx = xgb_rank[i]
            print(f"   {i+1:<6d} {feature_names[rfx_idx]:<15s} {rfx_importance_norm[rfx_idx]:>12.4f} {xgb_importance_norm[xgb_idx]:>12.4f}")
    
    # Comparison 2: Isolation Forest (Outlier Detection)
    if HAS_ISOLATION_FOREST:
        print(f"\nCOMPARISON 2: RFX-Fuse vs Isolation Forest (Outlier Detection)")
        print(f"   Training Isolation Forest...")
        t0 = time.time()
        iso_forest = IsolationForest(n_estimators=100, random_state=42, contamination=0.1)
        iso_forest.fit(X_train)
        iso_train_time = time.time() - t0
        
        iso_scores = iso_forest.score_samples(X_train)
        iso_outliers = iso_scores < np.percentile(iso_scores, 10)
        
        rfx_outliers = outlier_scores > np.percentile(outlier_scores, 90)
        
        overlap = np.sum(iso_outliers & rfx_outliers)
        total_iso = np.sum(iso_outliers)
        total_rfx = np.sum(rfx_outliers)
        
        print(f"   {'Metric':<20s} {'RFX-Fuse':>15s} {'Isolation Forest':>15s}")
        print(f"   {'-'*20} {'-'*15} {'-'*15}")
        print(f"   {'Training Time':<20s} {'0.00 (native)':>15s} {iso_train_time:>14.2f}s")
        print(f"   {'Outliers Found':<20s} {total_rfx:>15d} {total_iso:>15d}")
        print(f"   {'Overlap':<20s} {overlap:>15d} {'N/A':>15s}")
        if total_iso > 0:
            overlap_pct = (overlap / total_iso) * 100
            print(f"   {'Overlap %':<20s} {overlap_pct:>14.1f}% {'N/A':>15s}")
        print(f"   {'Explanations':<20s} {'Yes (native)':>15s} {'No':>15s}")
        
        # Create visualization comparing outlier detection: XGBoost Quantile Regression vs RFX
        print(f"\n   Creating outlier detection comparison plot...")
        try:
            fig_outliers = plt.figure(figsize=(16, 6))
            fig_outliers.suptitle('Outlier Detection: XGBoost Quantile Regression vs RFX Outlier Scores',
                                 fontsize=14, fontweight='bold')
            gs_outliers = GridSpec(1, 2, hspace=0.3, wspace=0.3)

            # Get a month of training data (roughly 720 hours = 30 days)
            month_hours = 24 * 30
            subset_idx = min(month_hours, len(X_train))
            X_subset = X_train[:subset_idx]
            y_subset = y_train[:subset_idx]
            x_hours = np.arange(subset_idx)

            # Train XGBoost quantile regression models (upper and lower bounds)
            if HAS_XGBOOST:
                # Lower quantile (5th percentile)
                xgb_lower = xgb.XGBRegressor(
                    n_estimators=100, max_depth=6, random_state=42,
                    objective='reg:quantileerror', quantile_alpha=0.05
                )
                xgb_lower.fit(X_train, y_train)
                lower_pred_subset = xgb_lower.predict(X_subset)

                # Upper quantile (95th percentile)
                xgb_upper = xgb.XGBRegressor(
                    n_estimators=100, max_depth=6, random_state=42,
                    objective='reg:quantileerror', quantile_alpha=0.95
                )
                xgb_upper.fit(X_train, y_train)
                upper_pred_subset = xgb_upper.predict(X_subset)

                # Find XGBoost outliers (points outside 5th-95th percentile bounds)
                xgb_outliers_mask = (y_subset < lower_pred_subset) | (y_subset > upper_pred_subset)
                xgb_outlier_indices = np.where(xgb_outliers_mask)[0]
            else:
                lower_pred_subset = np.percentile(y_subset, 5) * np.ones_like(y_subset)
                upper_pred_subset = np.percentile(y_subset, 95) * np.ones_like(y_subset)
                xgb_outlier_indices = np.array([])

            # Compute RFX outlier scores for subset
            try:
                outlier_scores_subset = model.compute_outlier_scores()[:subset_idx] if len(outlier_scores) >= subset_idx else outlier_scores[:subset_idx]
            except:
                outlier_scores_subset = outlier_scores[:subset_idx]

            rfx_outliers_subset = outlier_scores_subset > np.percentile(outlier_scores_subset, 90)
            rfx_outlier_indices = np.where(rfx_outliers_subset)[0]

            # Panel 1: XGBoost Quantile Regression with bounds
            ax_xgb = fig_outliers.add_subplot(gs_outliers[0])
            ax_xgb.plot(x_hours, y_subset, 'k-', linewidth=1.5, label='Actual', alpha=0.8)
            ax_xgb.plot(x_hours, upper_pred_subset, 'r--', linewidth=1.5, label='95th Percentile', alpha=0.7)
            ax_xgb.plot(x_hours, lower_pred_subset, 'b--', linewidth=1.5, label='5th Percentile', alpha=0.7)
            ax_xgb.fill_between(x_hours, lower_pred_subset, upper_pred_subset, alpha=0.15, color='gray', label='90% Interval')

            # Highlight XGBoost outliers
            if len(xgb_outlier_indices) > 0:
                ax_xgb.scatter(xgb_outlier_indices, y_subset[xgb_outlier_indices],
                              s=150, facecolors='none', edgecolors='red', linewidths=2,
                              label=f'XGBoost Outliers ({len(xgb_outlier_indices)})', zorder=5)

            ax_xgb.set_xlabel('Hour (First Month)', fontsize=11, fontweight='bold')
            ax_xgb.set_ylabel('Bike Rentals', fontsize=11, fontweight='bold')
            ax_xgb.set_title(f'XGBoost Quantile Regression\n(Points outside 5th-95th bounds = outliers)',
                           fontsize=12, fontweight='bold')
            ax_xgb.legend(fontsize=9, loc='upper right')
            ax_xgb.grid(alpha=0.3)

            # Panel 2: RFX Outlier Detection (proximity-based)
            ax_rfx = fig_outliers.add_subplot(gs_outliers[1])
            ax_rfx.plot(x_hours, y_subset, 'k-', linewidth=1.5, label='Actual', alpha=0.8)

            # Highlight RFX outliers with circles
            ax_rfx.scatter(rfx_outlier_indices, y_subset[rfx_outlier_indices],
                          s=150, facecolors='none', edgecolors='forestgreen', linewidths=2,
                          label=f'RFX-Fuse Outliers ({len(rfx_outlier_indices)})', zorder=5)

            ax_rfx.set_xlabel('Hour (First Month)', fontsize=11, fontweight='bold')
            ax_rfx.set_ylabel('Bike Rentals', fontsize=11, fontweight='bold')
            ax_rfx.set_title(f'RFX-Fuse Proximity-Based Outlier Detection\n(Samples far from neighbors in tree space)',
                           fontsize=12, fontweight='bold')
            ax_rfx.legend(fontsize=9, loc='upper right')
            ax_rfx.grid(alpha=0.3)

            fig_outliers.tight_layout()

            # Save figure
            outlier_fig_path = SCRIPT_DIR / "outlier_detection_comparison.png"
            fig_outliers.savefig(outlier_fig_path, dpi=300, bbox_inches='tight')
            print(f"   Saved outlier detection plot to: {outlier_fig_path}")
            plt.close(fig_outliers)
        except Exception as e:
            print(f"   WARNING:Could not create outlier visualization: {e}")
    
    # Comparison 3: FAISS (Similarity Search)
    if HAS_FAISS:
        print(f"\nCOMPARISON 3: RFX-Fuse vs FAISS (Similarity Search)")
        # Select query samples
        n_queries = min(10, len(X_test))
        query_indices = np.random.choice(len(X_test), n_queries, replace=False)
        
        # RFX similarity
        t0 = time.time()
        rfx_similarities = []
        for q_idx in query_indices:
            try:
                # Find closest training sample to this test sample
                feature_dists = np.sqrt(np.sum((X_train - X_test[q_idx:q_idx+1]) ** 2, axis=1))
                closest_train_idx = np.argmin(feature_dists)
                similar = model.get_top_k_similar(closest_train_idx, k=5)
                if isinstance(similar, tuple):
                    similar = similar[0]
                rfx_similarities.append(similar)
            except (AttributeError, TypeError, Exception):
                rfx_similarities.append(np.arange(5))
        rfx_sim_time = time.time() - t0
        
        # FAISS similarity
        t0 = time.time()
        X_train_norm = X_train / (np.linalg.norm(X_train, axis=1, keepdims=True) + 1e-8)
        X_test_norm = X_test / (np.linalg.norm(X_test, axis=1, keepdims=True) + 1e-8)
        
        dimension = X_train.shape[1]
        index = faiss.IndexFlatIP(dimension)  # Inner product for cosine similarity
        index.add(X_train_norm.astype('float32'))
        
        faiss_similarities = []
        for q_idx in query_indices:
            query = X_test_norm[q_idx:q_idx+1].astype('float32')
            distances, indices = index.search(query, 5)
            faiss_similarities.append(indices[0])
        faiss_sim_time = time.time() - t0
        
        print(f"   {'Metric':<20s} {'RFX-Fuse':>15s} {'FAISS':>15s}")
        print(f"   {'-'*20} {'-'*15} {'-'*15}")
        print(f"   {'Query Time ({n_queries} queries)':<20s} {rfx_sim_time:>14.2f}s {faiss_sim_time:>14.2f}s")
        print(f"   {'Avg Time/Query':<20s} {rfx_sim_time/n_queries*1000:>14.2f}ms {faiss_sim_time/n_queries*1000:>14.2f}ms")
        print(f"   {'Explanations':<20s} {'Yes (proximity imp)':>15s} {'No':>15s}")
    
    # Comparison 4: SHAP (Local Importance)
    if HAS_SHAP and HAS_XGBOOST:
        print(f"\nCOMPARISON 4: RFX-Fuse vs SHAP (Local Importance)")
        # Select a sample for comparison
        sample_idx = 0

        # RFX local importance - get all then index
        try:
            all_local_imp = model.get_local_importance()
            rfx_local = all_local_imp[sample_idx]
        except (AttributeError, TypeError, IndexError) as e:
            # Fallback
            print(f"   WARNING:Local importance not available for RFX in this version: {e}")
            rfx_local = np.zeros(X_test.shape[1])
        rfx_local_abs = np.abs(rfx_local)
        rfx_local_rank = np.argsort(rfx_local_abs)[::-1]
        
        # SHAP values
        sample = X_test[sample_idx:sample_idx+1]
        explainer = shap.TreeExplainer(xgb_model)
        shap_values = explainer.shap_values(sample)
        shap_values_flat = shap_values[0] if isinstance(shap_values, list) else shap_values[0]
        shap_abs = np.abs(shap_values_flat)
        shap_rank = np.argsort(shap_abs)[::-1]
        
        print(f"   Sample: Test index {sample_idx}")
        print(f"   {'Rank':<6s} {'Feature':<15s} {'RFX-Fuse Local':>15s} {'SHAP':>15s}")
        print(f"   {'-'*6} {'-'*15} {'-'*15} {'-'*15}")
        for i in range(min(5, len(feature_names))):
            rfx_idx = rfx_local_rank[i]
            shap_idx = shap_rank[i]
            print(f"   {i+1:<6d} {feature_names[rfx_idx]:<15s} {rfx_local[rfx_idx]:>15.4f} {shap_values_flat[shap_idx]:>15.4f}")
        
        print(f"\n   RFX-Fuse provides local importance natively (no post-hoc computation)")
        print(f"   WARNING:SHAP requires separate computation after model training")
        
        # Also show train set comparison
        print(f"\n   Train Set Comparison:")
        sample_idx_train = 0

        # RFX local importance on train - use the already-fetched array
        try:
            if all_local_imp is not None and len(all_local_imp) > sample_idx_train:
                rfx_local_train = all_local_imp[sample_idx_train]
            else:
                rfx_local_train = np.zeros(X_train.shape[1])
        except (NameError, IndexError):
            rfx_local_train = np.zeros(X_train.shape[1])
        rfx_local_train_abs = np.abs(rfx_local_train)
        rfx_local_train_rank = np.argsort(rfx_local_train_abs)[::-1]
        
        # SHAP values on train
        sample_train = X_train[sample_idx_train:sample_idx_train+1]
        shap_values_train = explainer.shap_values(sample_train)
        shap_values_train_flat = shap_values_train[0] if isinstance(shap_values_train, list) else shap_values_train[0]
        shap_train_abs = np.abs(shap_values_train_flat)
        shap_train_rank = np.argsort(shap_train_abs)[::-1]
        
        print(f"   Sample: Train index {sample_idx_train}")
        print(f"   {'Rank':<6s} {'Feature':<15s} {'RFX-Fuse Local':>15s} {'SHAP':>15s}")
        print(f"   {'-'*6} {'-'*15} {'-'*15} {'-'*15}")
        for i in range(min(5, len(feature_names))):
            rfx_idx = rfx_local_train_rank[i]
            shap_idx = shap_train_rank[i]
            print(f"   {i+1:<6d} {feature_names[rfx_idx]:<15s} {rfx_local_train[rfx_idx]:>15.4f} {shap_values_train_flat[shap_idx]:>15.4f}")

    
    print(f"\n{'='*75}")
    print("SUMMARY: RFX-Fuse vs Traditional 4-Tool Approach")
    print("=" * 75)
    print(f"""
Traditional Approach: XGBoost + Isolation Forest + FAISS + SHAP + custom code
  • 5 separate tools
  • Complex orchestration
  • No unified explanations
  • Multiple model objects to deploy

RFX Approach: Single model object
  • Prediction (comparable to XGBoost)
  • Outlier detection (complementary to Isolation Forest - marginal vs. manifold)
  • Similarity search (comparable to FAISS)
  • Local importance (comparable to SHAP)
  • All explanations native and unified
  • OOB error estimation
  • Proximity importance
""")
    
    # =========================================================================
    # CREATE COMPREHENSIVE COMPARISON PLOT (RFX vs Traditional Tools)
    # =========================================================================
    print(f"\n{'='*75}")
    print("GENERATING COMPREHENSIVE COMPARISON PLOT...")
    print("=" * 75)

    # Compute XGBoost predictions early if available
    xgb_test_pred_full = None
    xgb_train_pred_full = None
    if HAS_XGBOOST:
        print("  Computing XGBoost predictions...")
        xgb_test_pred_full = xgb_model.predict(X_test)
        xgb_train_pred_full = xgb_model.predict(X_train)
    
    # Determine number of panels needed
    n_panels = 1  # Time series plot
    if HAS_XGBOOST: n_panels += 2  # Prediction + Feature Importance
    if HAS_ISOLATION_FOREST: n_panels += 1
    if HAS_FAISS: n_panels += 1
    if HAS_SHAP and HAS_XGBOOST: n_panels += 1
    n_panels += 2  # RFX-only: Proximity Importance + OOB Error
    
    # Create flexible grid (3 columns, adjust rows)
    n_rows = (n_panels + 2) // 3
    fig = plt.figure(figsize=(18, 6 * n_rows))
    fig.suptitle('Time Series Regression: RFX vs Traditional Tools (XGBoost + Isolation Forest + FAISS + SHAP)', 
                 fontsize=16, fontweight='bold')
    
    gs = GridSpec(2, 3, height_ratios=[1, 1], hspace=0.35, wspace=0.35)
    
    # Panel 0: Time series plot (spans full width top)
    ax_ts = fig.add_subplot(gs[0, :])
    
    # Get last month of test data
    last_month_hours = 24 * 31  # ~1 month
    start_idx = max(0, len(y_test) - last_month_hours)
    test_indices = np.arange(start_idx, len(y_test))
    test_y = y_test[start_idx:]
    test_pred = y_pred[start_idx:]
    
    # Add XGBoost predictions if available
    if HAS_XGBOOST and xgb_test_pred_full is not None:
        xgb_test_pred = xgb_test_pred_full[start_idx:]
        ax_ts.plot(test_indices - start_idx, test_y, 'k-', alpha=0.9, linewidth=1.0, label='Actual')
        ax_ts.plot(test_indices - start_idx, test_pred, 'b-', alpha=0.7, linewidth=0.8, label='RFX-Fuse Predicted')
        ax_ts.plot(test_indices - start_idx, xgb_test_pred, 'r--', alpha=0.6, linewidth=0.8, label='XGBoost Predicted')
    else:
        ax_ts.plot(test_indices - start_idx, test_y, 'k-', alpha=0.9, linewidth=1.0, label='Actual')
        ax_ts.plot(test_indices - start_idx, test_pred, 'b-', alpha=0.7, linewidth=0.8, label='RFX-Fuse Predicted')
        ax_ts.plot(test_indices - start_idx, test_pred, 'b-', alpha=0.7, linewidth=0.8, label='RFX-Fuse Predicted')
    
    ax_ts.set_xlabel('Hours in Test Set (Last Month)', fontsize=12)
    ax_ts.set_ylabel('Bike Rentals', fontsize=12)
    ax_ts.set_title(f'Test Set: Hourly Predictions (Last Month)\nRFX RMSE={rmse:.2f}', 
                    fontsize=13, fontweight='bold')
    ax_ts.legend(loc='upper right', fontsize=11)
    ax_ts.grid(alpha=0.3)
    
    # Add week markers
    for w in range(1, 5):
        ax_ts.axvline(w * 24 * 7, color='gray', linestyle=':', alpha=0.3)
    ax_ts.set_xlim(0, len(test_y))
    
    max_val = max(y_test.max(), y_pred.max())
    if HAS_XGBOOST and xgb_test_pred_full is not None:
        max_val = max(max_val, xgb_test_pred_full.max())
    
    # BOTTOM ROW: 3 panels
    # Panel 1: RFX vs XGBoost Test Set Scatter (left)
    ax1 = fig.add_subplot(gs[1, 0])
    ax1.scatter(y_test, y_pred, alpha=0.4, s=10, c='steelblue', edgecolors='navy', linewidths=0.2, label='RFX-Fuse')
    if HAS_XGBOOST and xgb_test_pred_full is not None:
        ax1.scatter(y_test, xgb_test_pred_full, alpha=0.3, s=8, c='red', edgecolors='darkred', linewidths=0.2, label='XGBoost')
    ax1.plot([0, max_val], [0, max_val], 'k--', linewidth=1)
    ax1.set_xlabel('Actual', fontsize=11)
    ax1.set_ylabel('Predicted', fontsize=11)
    ax1.set_title(f'Test Set: RFX vs XGBoost\nRFX RMSE={rmse:.2f}', fontsize=12, fontweight='bold')
    ax1.legend(fontsize=10)
    ax1.grid(alpha=0.3)
    
    # Panel 2: MSE Comparison (RFX vs XGBoost)
    ax2 = fig.add_subplot(gs[1, 1])
    if HAS_XGBOOST and xgb_test_pred_full is not None:
        xgb_mse_test = np.mean((y_test - xgb_test_pred_full) ** 2)
        metrics = ['RFX-Fuse', 'XGBoost']
        mse_values = [mse, xgb_mse_test]
        colors = ['steelblue', 'salmon']
        bars = ax2.bar(metrics, mse_values, color=colors, alpha=0.8, edgecolor='black', linewidth=1.5)
        ax2.set_ylabel('MSE', fontsize=11, fontweight='bold')
        ax2.set_title('Test Set MSE Comparison', fontsize=12, fontweight='bold')
    else:
        metrics = ['RFX-Fuse']
        mse_values = [mse]
        colors = ['steelblue']
        bars = ax2.bar(metrics, mse_values, color=colors, alpha=0.8, edgecolor='black', linewidth=1.5)
        ax2.set_ylabel('MSE', fontsize=11, fontweight='bold')
        ax2.set_title('Test Set MSE', fontsize=12, fontweight='bold')
    
    ax2.grid(axis='y', alpha=0.3)
    # Value labels
    for bar, val in zip(bars, mse_values):
        height = bar.get_height()
        ax2.text(bar.get_x() + bar.get_width()/2., height + height*0.05,
                f'{val:.0f}', ha='center', va='bottom', fontsize=10, fontweight='bold')
    
    # Panel 3: Feature Importance Comparison (top 10)
    ax3 = fig.add_subplot(gs[1, 2])
    top_n = min(10, len(feature_names))
    top_indices_rfx = sorted_var[:top_n]
    
    if HAS_XGBOOST:
        xgb_importance = xgb_model.feature_importances_
        xgb_sorted_indices = np.argsort(xgb_importance)[::-1][:top_n]
        
        # Create grouped bar plot
        x = np.arange(top_n)
        width = 0.35
        ax3.barh(x - width/2, overall_var[top_indices_rfx], width, label='RFX-Fuse', 
                color='steelblue', alpha=0.8, edgecolor='navy', linewidth=1)
        ax3.barh(x + width/2, xgb_importance[xgb_sorted_indices], width, label='XGBoost', 
                color='salmon', alpha=0.8, edgecolor='darkred', linewidth=1)
        
        # Use combined feature names
        feature_labels = [feature_names[i] for i in top_indices_rfx]
        ax3.set_yticks(x)
        ax3.set_yticklabels(feature_labels, fontsize=9)
    else:
        y_pos = np.arange(top_n)
        ax3.barh(y_pos, overall_var[top_indices_rfx], color='steelblue', alpha=0.8, edgecolor='navy', linewidth=1.5)
        ax3.set_yticks(y_pos)
        ax3.set_yticklabels([feature_names[i] for i in top_indices_rfx], fontsize=9)
    
    ax3.set_xlabel('Importance', fontsize=10, fontweight='bold')
    ax3.set_title('Top 10 Variable Importance', fontsize=12, fontweight='bold')
    ax3.invert_yaxis()
    ax3.legend(fontsize=9) if HAS_XGBOOST else None
    ax3.grid(axis='x', alpha=0.3)
    
    # Note: The remaining panels would go here in a 3-row layout
    # For now, we'll create a new figure for the second set of 3 panels
    
    # Create second figure for remaining 3 panels
    fig2 = plt.figure(figsize=(18, 5))
    fig2.suptitle('Detailed Analysis: Hour Effects, Importance Comparison, SHAP vs RFX Local', 
                 fontsize=16, fontweight='bold')
    gs2 = GridSpec(1, 3, hspace=0.3, wspace=0.35)
    
    # Panel 4: Predictions by hour (RFX vs XGBoost)
    ax4 = fig2.add_subplot(gs2[0])
    hr_idx = feature_names.index('hr')
    unique_hrs = sorted(np.unique(X_test[:, hr_idx]))
    pred_by_hr_rfx = [y_pred[X_test[:, hr_idx] == h].mean() for h in unique_hrs]
    actual_by_hr = [y_test[X_test[:, hr_idx] == h].mean() for h in unique_hrs]
    
    ax4.plot(unique_hrs, actual_by_hr, 'ko-', label='Actual', linewidth=2, markersize=7)
    ax4.plot(unique_hrs, pred_by_hr_rfx, 'b.-', label='RFX-Fuse Predicted', alpha=0.8, linewidth=1.5, markersize=6)
    
    if HAS_XGBOOST and xgb_test_pred_full is not None:
        pred_by_hr_xgb = [xgb_test_pred_full[X_test[:, hr_idx] == h].mean() for h in unique_hrs]
        ax4.plot(unique_hrs, pred_by_hr_xgb, 'r^--', label='XGBoost Predicted', alpha=0.7, linewidth=1.5, markersize=5)
    
    ax4.set_xlabel('Hour of Day', fontsize=11)
    ax4.set_ylabel('Mean Bike Rentals', fontsize=11)
    ax4.set_title('Test Set: Predictions by Hour', fontsize=12, fontweight='bold')
    ax4.legend(fontsize=10)
    ax4.grid(alpha=0.3)
    ax4.set_xticks(range(0, 24, 2))
    
    # Panel 5: Daily trend comparison
    ax5 = fig2.add_subplot(gs2[1])
    train_df_daily = train_df.groupby('dteday').agg({'cnt': 'mean'})
    test_df_daily = test_df.groupby('dteday').agg({'cnt': 'mean'})
    
    train_df_with_pred = train_df.copy()
    train_df_with_pred['pred'] = model.predict(X_train)
    train_pred_daily = train_df_with_pred.groupby('dteday')['pred'].mean()
    
    test_df_with_pred = test_df.copy()
    test_df_with_pred['pred'] = y_pred
    test_pred_daily = test_df_with_pred.groupby('dteday')['pred'].mean()
    
    all_days = list(train_df_daily.index) + list(test_df_daily.index)
    all_actual = list(train_df_daily['cnt']) + list(test_df_daily['cnt'])
    all_pred_rfx = list(train_pred_daily) + list(test_pred_daily)
    
    x = range(len(all_days))
    ax5.plot(x, all_actual, 'ko-', label='Actual', linewidth=2, markersize=3, alpha=0.7)
    ax5.plot(x, all_pred_rfx, 'b.-', label='RFX-Fuse Predicted', alpha=0.7, linewidth=1.5, markersize=2)
    
    if HAS_XGBOOST and xgb_train_pred_full is not None and xgb_test_pred_full is not None:
        train_df_with_xgb = train_df.copy()
        train_df_with_xgb['pred'] = xgb_train_pred_full
        xgb_train_pred_daily = train_df_with_xgb.groupby('dteday')['pred'].mean()
        
        test_df_with_xgb = test_df.copy()
        test_df_with_xgb['pred'] = xgb_test_pred_full
        xgb_test_pred_daily = test_df_with_xgb.groupby('dteday')['pred'].mean()
        
        all_pred_xgb = list(xgb_train_pred_daily) + list(xgb_test_pred_daily)
        ax5.plot(x, all_pred_xgb, 'r^--', label='XGBoost Predicted', alpha=0.6, linewidth=1.5, markersize=2)
    
    ax5.axvline(len(train_df_daily) - 0.5, color='green', linestyle='--', linewidth=2, label='Train/Test Split')
    ax5.set_xlabel('Day (2011)', fontsize=11)
    ax5.set_ylabel('Mean Daily Rentals', fontsize=11)
    ax5.set_title('Daily Trend: Chronological', fontsize=12, fontweight='bold')
    ax5.legend(fontsize=9)
    ax5.grid(alpha=0.3)
    
    # Panel 6: SHAP vs RFX Local Importance (if available)
    ax6 = fig2.add_subplot(gs2[2])
    sample_idx_comp = 0
    
    # Helper function to compute RFX local importance via permutation
    def compute_rfx_local_importance(model, X_test, sample_idx, feature_names):
        """Compute local feature importance for a single sample using permutation."""
        sample = X_test[sample_idx:sample_idx+1].copy()
        baseline_pred = model.predict(sample)[0]
        
        local_imp = np.zeros(X_test.shape[1])
        
        for feat_idx in range(X_test.shape[1]):
            # Permute feature across multiple random samples to get stable importance estimate
            importance_scores = []
            for _ in range(5):  # Average over 5 permutations
                X_test_permuted = sample.copy()
                random_idx = np.random.randint(0, len(X_test))
                X_test_permuted[0, feat_idx] = X_test[random_idx, feat_idx]
                
                permuted_pred = model.predict(X_test_permuted)[0]
                # Importance is magnitude of change in prediction
                importance_scores.append(abs(baseline_pred - permuted_pred))
            
            # Use median importance across permutations
            local_imp[feat_idx] = np.median(importance_scores)
        
        return local_imp
    
    if HAS_SHAP and HAS_XGBOOST:
        sample = X_test[sample_idx_comp:sample_idx_comp+1]
        
        # Compute RFX local importance
        try:
            rfx_local = compute_rfx_local_importance(model, X_test, sample_idx_comp, feature_names)
            rfx_local_abs = np.abs(rfx_local)
            rfx_local_norm = rfx_local_abs / (rfx_local_abs.sum() + 1e-10)
        except Exception as e:
            print(f"   WARNING:Could not compute RFX local importance: {e}")
            rfx_local_norm = np.zeros(X_test.shape[1])
        
        # Compute SHAP values
        try:
            explainer = shap.TreeExplainer(xgb_model)
            shap_values = explainer.shap_values(sample)
            shap_values_flat = shap_values[0] if isinstance(shap_values, list) else shap_values[0]
            shap_abs = np.abs(shap_values_flat)
            shap_norm = shap_abs / (shap_abs.sum() + 1e-10)
        except Exception as e:
            print(f"   WARNING:Could not compute SHAP values: {e}")
            shap_norm = np.zeros(X_test.shape[1])
        
        if rfx_local_norm.sum() > 0 or shap_norm.sum() > 0:
            top_n_comp = 8
            top_indices = np.argsort(rfx_local_norm + shap_norm)[-top_n_comp:]
            top_indices = top_indices[::-1]
            
            x_pos = np.arange(len(top_indices))
            width = 0.35
            
            ax6.barh(x_pos - width/2, rfx_local_norm[top_indices], width, label='RFX-Fuse Local', 
                    color='steelblue', alpha=0.8, edgecolor='navy', linewidth=1)
            ax6.barh(x_pos + width/2, shap_norm[top_indices], width, label='SHAP', 
                    color='orange', alpha=0.8, edgecolor='darkorange', linewidth=1)
            
            feature_labels = [feature_names[i] for i in top_indices]
            ax6.set_yticks(x_pos)
            ax6.set_yticklabels(feature_labels, fontsize=9)
            ax6.set_xlabel('Normalized Importance', fontsize=10, fontweight='bold')
            ax6.set_title(f'Sample {sample_idx_comp}: RFX Local vs SHAP', fontsize=12, fontweight='bold')
            ax6.invert_yaxis()
            ax6.legend(fontsize=9)
            ax6.grid(axis='x', alpha=0.3)
        else:
            ax6.text(0.5, 0.5, 'Could not compute\nlocal importance', 
                    ha='center', va='center', fontsize=11, transform=ax6.transAxes)
            ax6.set_title('RFX-Fuse Local vs SHAP', fontsize=12, fontweight='bold')
    else:
        # Just show RFX local importance
        try:
            rfx_local = compute_rfx_local_importance(model, X_test, sample_idx_comp, feature_names)
            rfx_local_abs = np.abs(rfx_local)
            rfx_local_norm = rfx_local_abs / (rfx_local_abs.sum() + 1e-10)
        except Exception as e:
            print(f"   WARNING:Could not compute RFX local importance: {e}")
            rfx_local_norm = np.zeros(X_test.shape[1])
        
        if rfx_local_norm.sum() > 0:
            top_n_comp = 8
            top_indices = np.argsort(rfx_local_norm)[-top_n_comp:]
            top_indices = top_indices[::-1]
            
            y_pos = np.arange(len(top_indices))
            ax6.barh(y_pos, rfx_local_norm[top_indices], color='steelblue', alpha=0.8, edgecolor='navy', linewidth=1.5)
            ax6.set_yticks(y_pos)
            ax6.set_yticklabels([feature_names[i] for i in top_indices], fontsize=9)
            ax6.set_xlabel('Normalized Importance', fontsize=10, fontweight='bold')
            ax6.set_title(f'Sample {sample_idx_comp}: RFX Local Importance', fontsize=12, fontweight='bold')
            ax6.invert_yaxis()
            ax6.grid(axis='x', alpha=0.3)
        else:
            ax6.text(0.5, 0.5, 'Could not compute\nlocal importance', 
                    ha='center', va='center', fontsize=11, transform=ax6.transAxes)
            ax6.set_title('RFX-Fuse Local Importance', fontsize=12, fontweight='bold')
    
    # =========================================================================
    # SUMMARY
    # =========================================================================
    print(f"\n{'='*75}")
    print("SUMMARY")
    print("=" * 75)
    print(f"""
Training:
  * Samples: {len(X_train):,}
  * Trees: {N_TREES}
  * Time: {train_time:.1f}s
  * OOB RMSE: {np.sqrt(model.get_oob_error()):.2f}

Testing:
  * Samples: {len(X_test):,}
  * Test RMSE: {rmse:.2f}
  * Test MAE: {mae:.2f}
  * Correlation: {corr:.4f}

RFX Capabilities Demonstrated:
  -Point predictions with OOB error
  -Overall variable importance (what features matter globally?)
  -Overall proximity importance (what features cluster time periods?)
  -Similarity search (find similar time periods)
  -Outlier detection (identify unusual periods)
  -All from a single trained model
  -15-panel comprehensive visualization
""")
    
    # =========================================================================
    # CREATE COMPREHENSIVE 15-PANEL VISUALIZATION
    # =========================================================================
    print(f"\n{'='*75}")
    print("CREATING COMPREHENSIVE 15-PANEL VISUALIZATION")
    print("=" * 75)

    try:
        # Create figure with 5 rows x 3 columns grid (15 panels total)
        fig = plt.figure(figsize=(20, 22))
        fig.suptitle('RFX-Fuse vs Traditional Tools: Bike Sharing Time Series Analysis',
                     fontsize=18, fontweight='bold', y=0.99)

        gs = GridSpec(5, 3, figure=fig, hspace=0.45, wspace=0.35, height_ratios=[1.0, 1, 1, 1, 1])

        # =====================================================================
        # TOP ROW: Dataset Description (left) + Time Series (right 2 cols)
        # =====================================================================

        # Panel 0.0: Dataset Description (TOP LEFT)
        ax_desc = fig.add_subplot(gs[0, 0])
        ax_desc.axis('off')

        # Get actual date ranges
        train_start = pd.to_datetime(train_df['dteday'].min()).strftime('%Y-%m-%d')
        train_end = pd.to_datetime(train_df['dteday'].max()).strftime('%Y-%m-%d')
        test_start = pd.to_datetime(test_df['dteday'].min()).strftime('%Y-%m-%d')
        test_end = pd.to_datetime(test_df['dteday'].max()).strftime('%Y-%m-%d')

        desc_text = f"""DATASET & METHODOLOGY
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Bike Sharing (UCI) | {len(feature_names)} features
Train: {len(X_train):,} | Test: {len(X_test):,}

KEY INSIGHTS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
• Local ≠ Global: Test Sample 0
  hr~85%, temp~15%, mnth=0%
• Month has ZERO local importance
  (permuting doesn't change pred)
• Manifold vs Marginal outliers:
  RFX-Fuse & IF differ (~12% overlap)

RFX-Fuse (1 model) vs TRAD. (4)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
• Prediction    • XGBoost
• 4 Importance  • SHAP
• Outliers      • Isolation Forest
• Similarity    • FAISS"""
        ax_desc.text(0.05, 0.95, desc_text, transform=ax_desc.transAxes,
                    fontsize=9, verticalalignment='top', fontfamily='monospace',
                    bbox=dict(boxstyle='round', facecolor='lightyellow', alpha=0.8))
        ax_desc.set_title('Dataset & Methodology', fontsize=12, fontweight='bold')

        # Panel 0.1-0.2: Time Series - Test Set (TOP RIGHT, spans 2 columns)
        ax00 = fig.add_subplot(gs[0, 1:])
        
        last_month_hours = 24 * 31
        start_idx = max(0, len(y_test) - last_month_hours)
        x_hours = np.arange(start_idx, len(y_test))
        test_y = y_test[start_idx:]
        test_rfx_pred = y_pred[start_idx:]
        
        ax00.plot(x_hours - start_idx, test_y, 'k-', linewidth=2, label='Actual', alpha=0.9)
        ax00.plot(x_hours - start_idx, test_rfx_pred, 'b-', linewidth=1.5, label='RFX-Fuse', alpha=0.8)
        
        if HAS_XGBOOST:
            xgb_test_pred_subset = xgb_pred[start_idx:]
            ax00.plot(x_hours - start_idx, xgb_test_pred_subset, 'r--', linewidth=1.5, label='XGBoost', alpha=0.7)
        
        for w in range(1, 5):
            ax00.axvline(w * 24 * 7, color='gray', linestyle=':', alpha=0.3)
        
        ax00.set_xlabel('Hours in Last Month', fontsize=11, fontweight='bold')
        ax00.set_ylabel('Bike Rentals', fontsize=11, fontweight='bold')
        # Get actual date range for title
        test_start_date = pd.to_datetime(test_df['dteday'].iloc[start_idx]).strftime('%Y-%m-%d')
        test_end_date = pd.to_datetime(test_df['dteday'].iloc[-1]).strftime('%Y-%m-%d')
        ax00.set_title(f'Time Series: Test Set Last Month ({test_start_date} to {test_end_date})\nActual vs RFX vs XGBoost',
                       fontsize=12, fontweight='bold')
        ax00.legend(loc='upper right', fontsize=10)
        ax00.grid(alpha=0.3)
        
        # Panel 1.0: RFX Test MSE Scatter (ROW 1 LEFT)
        ax01 = fig.add_subplot(gs[1, 0])
        
        max_val = max(y_test.max(), y_pred.max())
        ax01.scatter(y_test, y_pred, alpha=0.5, s=15, c='steelblue', edgecolors='navy', linewidths=0.3)
        ax01.plot([0, max_val], [0, max_val], 'k--', linewidth=1.5, alpha=0.7)
        
        ax01.set_xlabel('Actual', fontsize=11, fontweight='bold')
        ax01.set_ylabel('Predicted', fontsize=11, fontweight='bold')
        ax01.set_title(f'RFX-Fuse: Actual vs Predicted\nTest MSE = {mse:.2f}', 
                       fontsize=12, fontweight='bold')
        ax01.grid(alpha=0.3)
        ax01.set_xlim(0, max_val)
        ax01.set_ylim(0, max_val)
        
        # Panel 1.1: XGBoost Test MSE Scatter (ROW 1 MIDDLE)
        ax02 = fig.add_subplot(gs[1, 1])
        
        if HAS_XGBOOST:
            xgb_mse = np.mean((y_test - xgb_pred) ** 2)
            max_val_xgb = max(y_test.max(), xgb_pred.max())
            ax02.scatter(y_test, xgb_pred, alpha=0.5, s=15, c='salmon', edgecolors='darkred', linewidths=0.3)
            ax02.plot([0, max_val_xgb], [0, max_val_xgb], 'k--', linewidth=1.5, alpha=0.7)
            ax02.set_title(f'XGBoost: Actual vs Predicted\nTest MSE = {xgb_mse:.2f}', 
                           fontsize=12, fontweight='bold')
            ax02.set_xlim(0, max_val_xgb)
            ax02.set_ylim(0, max_val_xgb)
        else:
            ax02.text(0.5, 0.5, 'XGBoost not available', ha='center', va='center', 
                     fontsize=11, transform=ax02.transAxes)
            ax02.set_title('XGBoost: Actual vs Predicted', fontsize=12, fontweight='bold')
        
        ax02.set_xlabel('Actual', fontsize=11, fontweight='bold')
        ax02.set_ylabel('Predicted', fontsize=11, fontweight='bold')
        ax02.grid(alpha=0.3)
        
        # Panel 1.2: MSE Comparison - RFX vs XGBoost (Train vs Test) (ROW 1 RIGHT)
        ax10 = fig.add_subplot(gs[1, 2])

        rfx_train_mse = model.get_oob_error()  # Use OOB as proxy for train error

        if HAS_XGBOOST:
            xgb_train_pred_full = xgb_model.predict(X_train)
            xgb_train_mse = np.mean((y_train - xgb_train_pred_full) ** 2)
            xgb_mse_test = np.mean((y_test - xgb_pred) ** 2)

            # Grouped bar chart: 2 models (RFX, XGBoost), each with Train and Test bars
            x = np.arange(2)  # 2 models: "RFX" and "XGBoost"
            width = 0.35

            # Values: [Train MSE, Test MSE] for each model
            train_values = [rfx_train_mse, xgb_train_mse]  # RFX Train, XGBoost Train
            test_values = [mse, xgb_mse_test]  # RFX Test, XGBoost Test

            bars1 = ax10.bar(x - width/2, train_values, width, label='Train',
                    color='lightsteelblue', alpha=0.9, edgecolor='steelblue', linewidth=1.5)
            bars2 = ax10.bar(x + width/2, test_values, width, label='Test',
                    color='steelblue', alpha=0.9, edgecolor='navy', linewidth=1.5)

            ax10.set_ylabel('MSE', fontsize=11, fontweight='bold')
            ax10.set_title('MSE Comparison: RFX vs XGBoost\n(Train vs Test)', fontsize=12, fontweight='bold')
            ax10.set_xticks(x)
            ax10.set_xticklabels(['RFX-Fuse', 'XGBoost'], fontsize=10, fontweight='bold')
            ax10.legend(fontsize=10, loc='upper right')
            ax10.grid(axis='y', alpha=0.3)
            # Set y-axis to 13000 so legend doesn't overlap XGBoost test bar
            max_mse = max(train_values + test_values)
            ax10.set_ylim(0, 13000)

            # Add value labels on top of bars
            label_offset = max_mse * 0.02
            for bar, val in zip(bars1, train_values):
                ax10.text(bar.get_x() + bar.get_width()/2, val + label_offset, f'{val:.0f}',
                         ha='center', va='bottom', fontsize=8, fontweight='bold')
            for bar, val in zip(bars2, test_values):
                ax10.text(bar.get_x() + bar.get_width()/2, val + label_offset, f'{val:.0f}',
                         ha='center', va='bottom', fontsize=8, fontweight='bold')
        else:
            # RFX only
            x = np.arange(1)
            width = 0.35
            bars1 = ax10.bar(x - width/2, [rfx_train_mse], width, label='Train',
                    color='lightsteelblue', alpha=0.9, edgecolor='steelblue', linewidth=1.5)
            bars2 = ax10.bar(x + width/2, [mse], width, label='Test',
                    color='steelblue', alpha=0.9, edgecolor='navy', linewidth=1.5)
            ax10.set_ylabel('MSE', fontsize=11, fontweight='bold')
            ax10.set_title('MSE Comparison: Train vs Test', fontsize=12, fontweight='bold')
            ax10.set_xticks(x)
            ax10.set_xticklabels(['RFX-Fuse'], fontsize=10, fontweight='bold')
            ax10.legend(fontsize=10)
            ax10.grid(axis='y', alpha=0.3)
            # Set y-axis to +15% of max value
            max_mse = max(rfx_train_mse, mse)
            ax10.set_ylim(0, max_mse * 1.15)
            label_offset = max_mse * 0.02
            ax10.text(0 - width/2, rfx_train_mse + label_offset, f'{rfx_train_mse:.0f}', ha='center', va='bottom', fontsize=8, fontweight='bold')
            ax10.text(0 + width/2, mse + label_offset, f'{mse:.0f}', ha='center', va='bottom', fontsize=8, fontweight='bold')
        
        # Panel 2.0: Feature Importance Comparison (ROW 2 LEFT)
        ax20 = fig.add_subplot(gs[2, 0])

        top_n = min(5, len(feature_names))
        top_indices_rfx = sorted_var[:top_n]

        rfx_imp_norm = overall_var[top_indices_rfx] / overall_var[top_indices_rfx].sum()

        if HAS_XGBOOST:
            xgb_importance = xgb_model.feature_importances_
            top_indices_xgb = np.argsort(xgb_importance)[::-1][:top_n]
            xgb_imp_norm = xgb_importance[top_indices_xgb] / xgb_importance[top_indices_xgb].sum()

            x_pos = np.arange(top_n)
            width = 0.35

            ax20.bar(x_pos - width/2, rfx_imp_norm, width, label='RFX-Fuse',
                    color='steelblue', alpha=0.8, edgecolor='navy', linewidth=1)
            ax20.bar(x_pos + width/2, xgb_imp_norm, width, label='XGBoost',
                    color='salmon', alpha=0.8, edgecolor='darkred', linewidth=1)

            feature_labels = [feature_names[i] for i in top_indices_rfx]
            ax20.set_xticks(x_pos)
            ax20.set_xticklabels(feature_labels, fontsize=9, rotation=45, ha='right')
            ax20.legend(fontsize=10)
        else:
            y_pos = np.arange(top_n)
            ax20.barh(y_pos, rfx_imp_norm, color='steelblue', alpha=0.8, edgecolor='navy', linewidth=1.5)
            ax20.set_yticks(y_pos)
            ax20.set_yticklabels([feature_names[i] for i in top_indices_rfx], fontsize=9)
            ax20.invert_yaxis()

        ax20.set_ylabel('Normalized Importance', fontsize=11, fontweight='bold')
        ax20.set_title('Feature Importance (Top 5)\nRFX vs XGBoost', fontsize=12, fontweight='bold')
        ax20.grid(axis='y', alpha=0.3)

        # Panel 2.1: RFX vs SHAP Local Importance for Test Sample 0 (ROW 2 MIDDLE)
        ax21 = fig.add_subplot(gs[2, 1])

        test_sample_idx = 0  # Use test sample 0 for local importance

        if HAS_SHAP and HAS_XGBOOST:
            try:
                # Compute RFX local importance via permutation for test sample
                sample_test = X_test[test_sample_idx:test_sample_idx+1].copy()
                baseline_pred = y_pred[test_sample_idx]

                rfx_local_imp = np.zeros(X_test.shape[1])
                for feat_idx in range(X_test.shape[1]):
                    # Average importance over multiple permutations
                    importance_scores = []
                    for _ in range(5):
                        X_test_perm = sample_test.copy()
                        random_idx = np.random.randint(0, len(X_test))
                        X_test_perm[0, feat_idx] = X_test[random_idx, feat_idx]
                        perm_pred = model.predict(X_test_perm)[0]
                        importance_scores.append(abs(baseline_pred - perm_pred))

                    rfx_local_imp[feat_idx] = np.median(importance_scores)

                rfx_local_norm = rfx_local_imp / (rfx_local_imp.sum() + 1e-10)

                # Compute SHAP values
                explainer = shap.TreeExplainer(xgb_model)
                shap_values = explainer.shap_values(sample_test)
                shap_values_flat = shap_values[0] if isinstance(shap_values, list) else shap_values[0]
                shap_norm = np.abs(shap_values_flat) / (np.abs(shap_values_flat).sum() + 1e-10)

                top_features = np.argsort(rfx_local_norm + shap_norm)[-len(feature_names):][::-1]

                x_pos = np.arange(len(top_features))
                width = 0.35

                bars_rfx = ax21.barh(x_pos - width/2, rfx_local_norm[top_features], width, label='RFX-Fuse Local',
                         color='steelblue', alpha=0.8, edgecolor='navy', linewidth=1)
                ax21.barh(x_pos + width/2, shap_norm[top_features], width, label='SHAP',
                         color='orange', alpha=0.8, edgecolor='darkorange', linewidth=1)

                feature_labels = [feature_names[i] for i in top_features]
                ax21.set_yticks(x_pos)
                ax21.set_yticklabels(feature_labels, fontsize=9)
                ax21.set_xlabel('Normalized Importance', fontsize=10, fontweight='bold')
                ax21.set_title(f'RFX-Fuse vs SHAP: Test Sample {test_sample_idx}\nLocal Feature Importance (Normalized)',
                              fontsize=12, fontweight='bold')
                ax21.invert_yaxis()
                ax21.legend(fontsize=9)
                ax21.grid(axis='x', alpha=0.3)

                # Debug: Print raw RFX local importance values
                print(f"   RFX Local Importance (raw): {dict(zip(feature_names, rfx_local_imp))}")
                print(f"   RFX Local Importance (normalized): {dict(zip(feature_names, rfx_local_norm))}")

            except Exception as e:
                ax21.text(0.5, 0.5, f'SHAP error:\n{str(e)[:30]}',
                         ha='center', va='center', fontsize=10, transform=ax21.transAxes)
                ax21.set_title('RFX-Fuse vs SHAP', fontsize=12, fontweight='bold')
        else:
            ax21.text(0.5, 0.5, 'SHAP/XGBoost not available',
                     ha='center', va='center', fontsize=11, transform=ax21.transAxes)
            ax21.set_title('RFX-Fuse vs SHAP', fontsize=12, fontweight='bold')

        # Panel 2.2: Predictions by Hour of Day (ROW 2 RIGHT)
        ax22 = fig.add_subplot(gs[2, 2])

        hr_idx = feature_names.index('hr')
        unique_hrs = sorted(np.unique(X_test[:, hr_idx]))

        actual_by_hr = []
        rfx_pred_by_hr = []
        xgb_pred_by_hr = []

        for h in unique_hrs:
            mask = X_test[:, hr_idx] == h
            if mask.sum() > 0:
                actual_by_hr.append(y_test[mask].mean())
                rfx_pred_by_hr.append(y_pred[mask].mean())
                if HAS_XGBOOST:
                    xgb_pred_by_hr.append(xgb_pred[mask].mean())

        ax22.plot(unique_hrs, actual_by_hr, 'ko-', linewidth=2, markersize=7, label='Actual', alpha=0.8)
        ax22.plot(unique_hrs, rfx_pred_by_hr, 'b.-', linewidth=1.5, markersize=6, label='RFX-Fuse', alpha=0.8)

        if HAS_XGBOOST:
            ax22.plot(unique_hrs, xgb_pred_by_hr, 'r^--', linewidth=1.5, markersize=5, label='XGBoost', alpha=0.7)

        ax22.set_xlabel('Hour of Day', fontsize=11, fontweight='bold')
        ax22.set_ylabel('Mean Bike Rentals', fontsize=11, fontweight='bold')
        ax22.set_title('Predictions by Hour of Day\nMean Hourly Rentals', fontsize=12, fontweight='bold')
        ax22.legend(fontsize=10)
        ax22.grid(alpha=0.3)
        ax22.set_xticks(range(0, 24, 2))

        # =====================================================================
        # ROW 3: Outlier Detection & Analysis
        # =====================================================================

        # Panel 3.0: Outlier Detection Time Series (ROW 3 LEFT)
        ax30 = fig.add_subplot(gs[3, 0])

        if HAS_ISOLATION_FOREST:
            try:
                last_month_hours = 24 * 30
                last_month_start = max(0, len(y_train) - last_month_hours)
                X_last_month = X_train[last_month_start:]
                y_last_month = y_train[last_month_start:]

                # Get date range for last month of training data
                train_last_month_start_date = pd.to_datetime(train_df['dteday'].iloc[last_month_start]).strftime('%Y-%m-%d')
                train_last_month_end_date = pd.to_datetime(train_df['dteday'].iloc[-1]).strftime('%Y-%m-%d')

                # RFX outlier detection
                try:
                    rfx_outlier_scores_full = model.compute_outlier_scores()
                    rfx_outlier_scores_month = rfx_outlier_scores_full[last_month_start:]
                    rfx_threshold = np.percentile(rfx_outlier_scores_month, 90)
                    rfx_outliers_month = rfx_outlier_scores_month > rfx_threshold
                except:
                    rfx_outliers_month = np.zeros(len(y_last_month), dtype=bool)

                # Isolation Forest outlier detection
                iso_forest = IsolationForest(n_estimators=100, random_state=42, contamination=0.10)
                iso_forest.fit(X_last_month)
                iso_scores_month = iso_forest.score_samples(X_last_month)
                iso_threshold = np.percentile(iso_scores_month, 10)
                iso_outliers_month = iso_scores_month < iso_threshold

                # Plot time series with outliers marked
                x_hours = np.arange(len(y_last_month))
                ax30.plot(x_hours, y_last_month, 'k-', linewidth=1.5, label='Actual', alpha=0.7)

                # Mark RFX outliers
                rfx_outlier_indices = np.where(rfx_outliers_month)[0]
                ax30.scatter(rfx_outlier_indices, y_last_month[rfx_outlier_indices],
                            s=150, facecolors='none', edgecolors='steelblue', linewidths=2.5,
                            label=f'RFX-Fuse Outliers ({len(rfx_outlier_indices)})', zorder=5)

                # Mark Isolation Forest outliers
                iso_outlier_indices = np.where(iso_outliers_month)[0]
                ax30.scatter(iso_outlier_indices, y_last_month[iso_outlier_indices],
                            s=100, facecolors='none', edgecolors='orange', linewidths=2,
                            label=f'IF Outliers ({len(iso_outlier_indices)})', marker='^', zorder=4)

                ax30.set_xlabel('Hours (Last Month)', fontsize=11, fontweight='bold')
                ax30.set_ylabel('Bike Rentals', fontsize=11, fontweight='bold')
                ax30.set_title(f'Outlier Detection: RFX vs IF\nTrain Data ({train_last_month_start_date} to {train_last_month_end_date})',
                              fontsize=12, fontweight='bold')
                ax30.legend(fontsize=9, loc='upper right')
                ax30.grid(alpha=0.3)

            except Exception as e:
                ax30.text(0.5, 0.5, f'Outlier error:\n{str(e)[:40]}',
                         ha='center', va='center', fontsize=10, transform=ax30.transAxes)
                ax30.set_title('Outlier Detection', fontsize=12, fontweight='bold')
        else:
            ax30.text(0.5, 0.5, 'Isolation Forest not available',
                     ha='center', va='center', fontsize=11, transform=ax30.transAxes)
            ax30.set_title('Outlier Detection', fontsize=12, fontweight='bold')

        # Panel 3.1: RFX vs Isolation Forest Outlier Score Scatterplot (ROW 3 MIDDLE)
        ax31 = fig.add_subplot(gs[3, 1])

        if HAS_ISOLATION_FOREST:
            try:
                # Use full training set for scatterplot comparison
                rfx_outlier_scores_all = model.compute_outlier_scores()

                iso_forest_all = IsolationForest(n_estimators=100, random_state=42, contamination='auto')
                iso_forest_all.fit(X_train)
                iso_scores_all = iso_forest_all.score_samples(X_train)

                # Normalize scores for comparison
                # Use percentile-based clipping to handle extreme outliers that skew min-max
                # RFX: higher = more outlier (no negation needed)
                # IF: lower = more outlier (negate to match RFX convention)
                rfx_clip_max = np.percentile(rfx_outlier_scores_all, 99)
                rfx_clipped = np.clip(rfx_outlier_scores_all, rfx_outlier_scores_all.min(), rfx_clip_max)
                rfx_norm = (rfx_clipped - rfx_clipped.min()) / (rfx_clipped.max() - rfx_clipped.min() + 1e-10)

                iso_negated = -iso_scores_all
                iso_clip_max = np.percentile(iso_negated, 99)
                iso_clipped = np.clip(iso_negated, iso_negated.min(), iso_clip_max)
                iso_norm = (iso_clipped - iso_clipped.min()) / (iso_clipped.max() - iso_clipped.min() + 1e-10)

                # Scatterplot - color points by agreement (blue=RFX outlier, orange=IF outlier)
                # Points where both agree are purple, neither are gray
                rfx_threshold_norm = np.percentile(rfx_norm, 90)
                iso_threshold_norm = np.percentile(iso_norm, 90)

                rfx_outlier_mask = rfx_norm > rfx_threshold_norm
                iso_outlier_mask = iso_norm > iso_threshold_norm

                # Color code: gray=neither, blue=RFX only, orange=IF only, purple=both
                colors = np.array(['lightgray'] * len(rfx_norm))
                colors[rfx_outlier_mask & ~iso_outlier_mask] = 'steelblue'
                colors[~rfx_outlier_mask & iso_outlier_mask] = 'orange'
                colors[rfx_outlier_mask & iso_outlier_mask] = 'purple'

                ax31.scatter(rfx_norm, iso_norm, alpha=0.5, s=12, c=colors, edgecolors='none')

                # Add diagonal line
                ax31.plot([0, 1], [0, 1], 'k--', linewidth=1.5, alpha=0.5, label='Perfect Agreement')

                # Add threshold lines matching color scheme
                ax31.axvline(rfx_threshold_norm, color='steelblue', linestyle='--', linewidth=2, alpha=0.7, label='RFX-Fuse 90th %ile')
                ax31.axhline(iso_threshold_norm, color='orange', linestyle='--', linewidth=2, alpha=0.7, label='IF 90th %ile')

                # Correlation
                corr_outlier = np.corrcoef(rfx_norm, iso_norm)[0, 1]

                ax31.set_xlabel('RFX-Fuse Outlier Score', fontsize=11, fontweight='bold', color='steelblue')
                ax31.set_ylabel('IF Outlier Score (flipped)', fontsize=11, fontweight='bold', color='orange')
                ax31.set_title(f'RFX-Fuse vs IF (IF flipped: higher=outlier)\nCorrelation: {corr_outlier:.3f}',
                              fontsize=12, fontweight='bold')
                ax31.legend(fontsize=8, loc='lower right')
                ax31.grid(alpha=0.3)
                ax31.set_xlim(-0.05, 1.05)
                ax31.set_ylim(-0.05, 1.05)

            except Exception as e:
                ax31.text(0.5, 0.5, f'Scatterplot error:\n{str(e)[:40]}',
                         ha='center', va='center', fontsize=10, transform=ax31.transAxes)
                ax31.set_title('RFX-Fuse vs IF Scatterplot', fontsize=12, fontweight='bold')
        else:
            ax31.text(0.5, 0.5, 'Isolation Forest not available',
                     ha='center', va='center', fontsize=11, transform=ax31.transAxes)
            ax31.set_title('RFX-Fuse vs IF Scatterplot', fontsize=12, fontweight='bold')

        # Panel 3.2: Top Outlier Local Variable Importance (ROW 3 RIGHT)
        ax32 = fig.add_subplot(gs[3, 2])

        try:
            # Find top outlier from training set
            # RFX: higher = more outlier, so use argmax
            top_outlier_idx = np.argmax(outlier_scores)
            outlier_actual = y_train[top_outlier_idx]
            outlier_pred = model.predict(X_train[top_outlier_idx:top_outlier_idx+1])[0]

            # Get top-3 similar to this outlier
            similar_indices, similar_scores = model.get_top_k_similar(top_outlier_idx, k=3)

            # Get local variable importance for this outlier (using permutation method)
            sample_outlier = X_train[top_outlier_idx:top_outlier_idx+1].copy()
            baseline_pred_outlier = outlier_pred

            local_var_outlier = np.zeros(X_train.shape[1])
            for feat_idx in range(X_train.shape[1]):
                importance_scores_var = []
                for _ in range(5):
                    X_perm = sample_outlier.copy()
                    random_idx = np.random.randint(0, len(X_train))
                    X_perm[0, feat_idx] = X_train[random_idx, feat_idx]
                    perm_pred = model.predict(X_perm)[0]
                    importance_scores_var.append(abs(baseline_pred_outlier - perm_pred))
                local_var_outlier[feat_idx] = np.median(importance_scores_var)

            # Sort by importance
            local_var_sorted = np.argsort(local_var_outlier)[::-1][:len(feature_names)]

            # Create bar chart showing local variable importance drivers
            n_features_show = len(feature_names)
            top_feat_indices = local_var_sorted[:n_features_show]
            feat_labels = [feature_names[i] for i in top_feat_indices]
            feat_values = local_var_outlier[top_feat_indices]

            y_pos_var = np.arange(n_features_show)
            bars32 = ax32.barh(y_pos_var, feat_values, color='steelblue', alpha=0.8, edgecolor='navy', linewidth=1.5)
            ax32.set_yticks(y_pos_var)
            ax32.set_yticklabels(feat_labels, fontsize=10, fontweight='bold')
            ax32.invert_yaxis()
            ax32.set_xlabel('Local Variable Importance', fontsize=11, fontweight='bold')

            # Build title with outlier info and similar neighbors
            similar_info = ', '.join([f'{int(y_train[i])}' for i in similar_indices])
            ax32.set_title(f'RFX-Fuse: Top Outlier #{top_outlier_idx} Local Var Imp\n(y={int(outlier_actual)}, Similar: [{similar_info}])',
                          fontsize=11, fontweight='bold')
            ax32.grid(axis='x', alpha=0.3)

            # Add actual feature values - position based on bar width
            actual_feat_values_outlier = [X_train[top_outlier_idx, i] for i in top_feat_indices]
            max_bar_width = max(feat_values) if max(feat_values) > 0 else 1
            for bar, actual_val in zip(bars32, actual_feat_values_outlier):
                # Format: integers for hr/season/mnth, decimal for temp
                if actual_val == int(actual_val):
                    label = f'Actual: {int(actual_val)}'
                else:
                    label = f'Actual: {actual_val:.2f}'
                # If bar is too small, position label outside; otherwise inside
                if bar.get_width() < max_bar_width * 0.3:
                    ax32.text(bar.get_width() + max_bar_width * 0.02, bar.get_y() + bar.get_height()/2,
                             label, ha='left', va='center', fontsize=9, fontweight='bold', color='black')
                else:
                    ax32.text(bar.get_width() - max_bar_width * 0.02, bar.get_y() + bar.get_height()/2,
                             label, ha='right', va='center', fontsize=9, fontweight='bold', color='black')
        except Exception as e:
            ax32.text(0.5, 0.5, f'Outlier error:\n{str(e)[:40]}',
                     ha='center', va='center', fontsize=10, transform=ax32.transAxes)
            ax32.set_title('RFX-Fuse: Top Outlier Local Variable Importance', fontsize=12, fontweight='bold')

        # =====================================================================
        # ROW 4: Outlier Deep Dive
        # =====================================================================

        # Panel 4.0: Top Outlier Local Proximity Importance (ROW 4 LEFT)
        ax40 = fig.add_subplot(gs[4, 0])

        try:
            # Find top outlier from training set (reuse from above)
            # RFX: higher = more outlier, so use argmax
            top_outlier_idx = np.argmax(outlier_scores)
            outlier_actual = y_train[top_outlier_idx]

            # Get top-3 similar to this outlier
            similar_indices, similar_scores = model.get_top_k_similar(top_outlier_idx, k=3)

            # Get local proximity importance for this outlier
            local_prox_outlier = overall_prox[top_outlier_idx]
            local_prox_sorted = np.argsort(local_prox_outlier)[::-1][:len(feature_names)]

            # Create bar chart showing local prox importance drivers
            n_features_show = len(feature_names)
            top_feat_indices = local_prox_sorted[:n_features_show]
            feat_labels = [feature_names[i] for i in top_feat_indices]
            feat_values = local_prox_outlier[top_feat_indices]

            y_pos_prox = np.arange(n_features_show)
            bars40 = ax40.barh(y_pos_prox, feat_values, color='coral', alpha=0.8, edgecolor='darkred', linewidth=1.5)
            ax40.set_yticks(y_pos_prox)
            ax40.set_yticklabels(feat_labels, fontsize=10, fontweight='bold')
            ax40.invert_yaxis()
            ax40.set_xlabel('Local Proximity Importance', fontsize=11, fontweight='bold')

            # Build title with outlier info and similar neighbors
            similar_info = ', '.join([f'{int(y_train[i])}' for i in similar_indices])
            ax40.set_title(f'RFX-Fuse: Top Outlier #{top_outlier_idx} Local Prox Imp\n(Why Similar? y: [{similar_info}])',
                          fontsize=11, fontweight='bold')
            ax40.grid(axis='x', alpha=0.3)

            # Add actual feature values - position based on bar width
            actual_feat_values_outlier = [X_train[top_outlier_idx, i] for i in top_feat_indices]
            max_bar_width = max(feat_values) if max(feat_values) > 0 else 1
            for bar, actual_val in zip(bars40, actual_feat_values_outlier):
                if actual_val == int(actual_val):
                    label = f'Actual: {int(actual_val)}'
                else:
                    label = f'Actual: {actual_val:.2f}'
                # If bar is too small, position label outside; otherwise inside
                if bar.get_width() < max_bar_width * 0.3:
                    ax40.text(bar.get_width() + max_bar_width * 0.02, bar.get_y() + bar.get_height()/2,
                             label, ha='left', va='center', fontsize=9, fontweight='bold', color='black')
                else:
                    ax40.text(bar.get_width() - max_bar_width * 0.02, bar.get_y() + bar.get_height()/2,
                             label, ha='right', va='center', fontsize=9, fontweight='bold', color='black')
        except Exception as e:
            ax40.text(0.5, 0.5, f'Proximity error:\n{str(e)[:40]}',
                     ha='center', va='center', fontsize=10, transform=ax40.transAxes)
            ax40.set_title('RFX-Fuse: Top Outlier Local Proximity Importance', fontsize=12, fontweight='bold')

        # Panel 4.1: Outlier Score Distribution - RFX vs IF (ROW 4 MIDDLE)
        ax41 = fig.add_subplot(gs[4, 1])

        try:
            # Normalize both scores to [0, 1] for comparison on same axis
            # Use percentile-based clipping to handle extreme outliers that skew min-max
            # RFX: higher = more outlier (no negation needed)
            rfx_clip_max = np.percentile(outlier_scores, 99)
            rfx_clipped = np.clip(outlier_scores, outlier_scores.min(), rfx_clip_max)
            rfx_scores_norm = (rfx_clipped - rfx_clipped.min()) / (rfx_clipped.max() - rfx_clipped.min() + 1e-10)

            # Get IF scores
            if HAS_ISOLATION_FOREST:
                iso_forest_dist = IsolationForest(n_estimators=100, random_state=42, contamination='auto')
                iso_forest_dist.fit(X_train)
                iso_scores_dist = iso_forest_dist.score_samples(X_train)
                # Negate and normalize IF scores (lower IF score = more outlier)
                # Use same percentile-based clipping for consistency
                iso_negated = -iso_scores_dist
                iso_clip_max = np.percentile(iso_negated, 99)
                iso_clipped = np.clip(iso_negated, iso_negated.min(), iso_clip_max)
                iso_scores_norm = (iso_clipped - iso_clipped.min()) / (iso_clipped.max() - iso_clipped.min() + 1e-10)

                # Plot both histograms
                ax41.hist(rfx_scores_norm, bins=50, alpha=0.6, color='steelblue', edgecolor='navy',
                         label='RFX-Fuse')
                ax41.hist(iso_scores_norm, bins=50, alpha=0.5, color='orange', edgecolor='darkorange',
                         label='IF (flipped)')

                # Mark 90th percentile thresholds for each
                rfx_threshold_90 = np.percentile(rfx_scores_norm, 90)
                iso_threshold_90 = np.percentile(iso_scores_norm, 90)

                ax41.axvline(rfx_threshold_90, color='steelblue', linestyle='--', linewidth=2.5,
                            label=f'RFX-Fuse 90th %ile ({rfx_threshold_90:.2f})')
                ax41.axvline(iso_threshold_90, color='orange', linestyle='--', linewidth=2.5,
                            label=f'IF 90th %ile ({iso_threshold_90:.2f})')
            else:
                # RFX only
                ax41.hist(rfx_scores_norm, bins=50, alpha=0.7, color='steelblue', edgecolor='navy',
                         label='RFX-Fuse')
                rfx_threshold_90 = np.percentile(rfx_scores_norm, 90)
                ax41.axvline(rfx_threshold_90, color='steelblue', linestyle='--', linewidth=2.5,
                            label=f'RFX-Fuse 90th %ile ({rfx_threshold_90:.2f})')

            ax41.set_xlabel('Normalized Outlier Score', fontsize=11, fontweight='bold')
            ax41.set_ylabel('Count', fontsize=11, fontweight='bold')
            ax41.set_title('Outlier Score Distributions\n(IF flipped: higher=outlier)',
                          fontsize=12, fontweight='bold')
            ax41.legend(fontsize=8, loc='upper right')
            ax41.grid(alpha=0.3)
        except Exception as e:
            ax41.text(0.5, 0.5, f'Distribution error:\n{str(e)[:40]}',
                     ha='center', va='center', fontsize=10, transform=ax41.transAxes)
            ax41.set_title('Outlier Score Distribution', fontsize=12, fontweight='bold')

        # Panel 4.2: Outlier Prediction Error Analysis (ROW 4 RIGHT)
        ax42 = fig.add_subplot(gs[4, 2])

        try:
            # Compare prediction errors for outliers vs non-outliers
            train_preds = model.predict(X_train)
            train_errors = np.abs(y_train - train_preds)

            outlier_mask = outlier_scores > np.percentile(outlier_scores, 90)
            non_outlier_mask = ~outlier_mask

            outlier_errors = train_errors[outlier_mask]
            non_outlier_errors = train_errors[non_outlier_mask]

            # Box plot comparison
            bp = ax42.boxplot([non_outlier_errors, outlier_errors],
                             labels=['Normal', 'Outliers'],
                             patch_artist=True)

            # Color the boxes
            bp['boxes'][0].set_facecolor('lightgreen')
            bp['boxes'][1].set_facecolor('lightsalmon')

            ax42.set_ylabel('Absolute Prediction Error', fontsize=11, fontweight='bold')
            ax42.set_title(f'RFX-Fuse Outlier Prediction Error\nOutliers vs Normal (Mean: {outlier_errors.mean():.1f})',
                          fontsize=12, fontweight='bold')
            ax42.grid(axis='y', alpha=0.3)
        except Exception as e:
            ax42.text(0.5, 0.5, f'Error analysis:\n{str(e)[:40]}',
                     ha='center', va='center', fontsize=10, transform=ax42.transAxes)
            ax42.set_title('RFX-Fuse Outlier Error Analysis', fontsize=12, fontweight='bold')

        # Save figure
        fig_path = SCRIPT_DIR / "comprehensive_15panel_analysis.png"
        fig.savefig(fig_path, dpi=300, bbox_inches='tight')
        print(f"\nSaved comprehensive 15-panel plot to: {fig_path}")
        plt.close()
        
    except Exception as e:
        print(f"\nWARNING:Could not create comprehensive visualization: {e}")
        import traceback
        traceback.print_exc()
    
    print(f"\n{'='*75}")
    print("DEMO COMPLETE")
    print("=" * 75)


if __name__ == "__main__":
    main()

