🌍 Notebook 5 β€” Scenario Forecasting and Macroeconomic Simulations (2025)ΒΆ

This notebook extends the analytical framework into forward-looking scenario analysis.
Using trained machine-learning models (XGBoost and LightGBM), it explores how alternative macroeconomic conditions β€” such as changes in GDP, turnover, and policy stringency β€” could affect hotel demand across EU countries during 2025.

The purpose is to translate quantitative forecasts into policy-relevant insights, linking econometric estimation with real-world resilience analysis.


🎯 Objectives¢

  • Define consistent macroeconomic scenarios (Baseline, Optimistic, Pessimistic, Policy).
  • Simulate and compare hotel-demand forecasts under alternative macro shocks.
  • Quantify elasticities and sensitivities of demand to GDP and policy variation.
  • Visualize log-scale trajectories and percentage deviations from baseline.
  • Summarize and export scenario outcomes for cross-country comparison and policy use.

Structure OverviewΒΆ

  1. Environment Setup
  2. Load Baseline Data and Trained Models
  3. Define Macroeconomic Scenarios
  4. Simulate Forecasts for Each Scenario
  5. Merge Baseline and Scenario Results
  6. Visualize Scenario Comparisons (GDP Shocks)
  7. Compute % Deviations vs Baseline
  8. Estimate GDP Elasticity Across EU Countries
  9. Export Scenario and Elasticity Results

Inputs
πŸ“‚ ../data/processed/hotel_predictions.csv
πŸ“‚ ../outputs/models/pipe_xgb.pkl
πŸ“‚ ../outputs/models/pipe_lgbm.pkl

Outputs
πŸ“‚ ../data/processed/hotel_scenario_results.csv β€” all simulated scenarios (baseline + shocks)
πŸ“‚ ../data/processed/hotel_scenario_gdp_merged.csv β€” merged baseline + GDP scenarios
πŸ“‚ ../outputs/reports/gdp_elasticity_summary.csv β€” country-level GDP-demand elasticities
πŸ“‚ ../outputs/figures/gdp_elasticity_by_country.png β€” elasticity ranking by region
πŸ“‚ ../outputs/figures/scenario_forecast_comparison_DE.png β€” Germany: Baseline vs GDP Scenarios
πŸ“‚ ../outputs/figures/scenario_forecast_comparison_FR.png β€” France: Baseline vs GDP Scenarios
πŸ“‚ ../outputs/figures/scenario_forecast_comparison_ES.png β€” Spain: Baseline vs GDP Scenarios
πŸ“‚ ../outputs/figures/scenario_forecast_comparison_IT.png β€” Italy: Baseline vs GDP Scenarios
πŸ“‚ ../outputs/figures/scenario_forecast_comparison_PL.png β€” Poland: Baseline vs GDP Scenarios


🌍 Policy Relevance¢

Scenario-based forecasting helps identify which tourism markets are most exposed to macroeconomic fluctuations.
By quantifying the responsiveness of hotel demand to GDP and policy shocks, this analysis supports evidence-based recovery planning and targeted intervention design.

🧭 Interpretation β€” GDP Elasticity of Hotel DemandΒΆ

Scenario simulations highlight pronounced cross-country heterogeneity in how hotel demand responds to macroeconomic changes:

  • Highly elastic economies β€” Luxembourg (LU), Spain (ES), Latvia (LV), Netherlands (NL) β€” exhibit elasticity > 1, meaning hotel activity amplifies economic cycles (booms ↑↑, downturns ↓↓).
  • Moderately elastic markets β€” Slovenia (SI), Cyprus (CY), Croatia (HR) β€” show balanced sensitivity, combining cyclical responsiveness with underlying resilience.
  • Low-elasticity countries β€” Germany (DE), France (FR), Belgium (BE) β€” display stable, business-driven demand largely insulated from short-term GDP fluctuations.

Overall, smaller, tourism-intensive economies tend to experience greater volatility in hotel activity, while larger, diversified economies act as stabilizers within the European tourism system.


πŸ’‘ Policy Insight
GDP-based scenario modeling provides a framework for stress-testing tourism resilience.
Country-level elasticities help policymakers prioritize counter-cyclical support and targeted investment where macro shocks would exert the greatest impact.

Source β€” Model-based scenario simulations, forecast horizon Jan–Aug 2025.

InΒ [1]:
# %% ===============================================================
# STEP 0 β€” ENVIRONMENT SETUP
# ===============================================================
# ruff: noqa: E402

import sys
from pathlib import Path

# --- Project path setup ---
project_root = Path.cwd().parent
if str(project_root) not in sys.path:
    sys.path.append(str(project_root))

# --- Imports ---
from utils.scenarios import simulate_scenario, plot_gdp_scenarios, calculate_impact, calculate_elasticity

import pandas as pd
from pathlib import Path
import matplotlib.pyplot as plt
import seaborn as sns
import joblib
import warnings

warnings.filterwarnings("ignore")

plt.style.use("seaborn-v0_8-whitegrid")
sns.set_palette("viridis")

BASE_DIR = Path("..")
DATA_PROCESSED = BASE_DIR / "data" / "processed"
MODELS = BASE_DIR / "outputs" / "models"
FIGURES = BASE_DIR / "outputs" / "figures"

for path in [DATA_PROCESSED, MODELS, FIGURES]:
    path.mkdir(parents=True, exist_ok=True)

print("βœ… Environment setup complete.")
βœ… Environment setup complete.
InΒ [2]:
# %% ===============================================================
# STEP 1 β€” LOAD BASELINE DATA & MODELS
# ===============================================================
df = pd.read_csv(DATA_PROCESSED / "hotel_predictions.csv", parse_dates=["month"])
df["region"] = df["region"].str.strip().str.upper()
df["month"] = df["month"].dt.to_period("M").dt.to_timestamp()

print(f"βœ… Data loaded: {df.shape[0]} rows Γ— {df.shape[1]} columns")
print("πŸ“… Range:", df["month"].min().date(), "β†’", df["month"].max().date())

# Load pipelines
pipe_xgb = joblib.load(MODELS / "pipe_xgb.pkl")
pipe_lgbm = joblib.load(MODELS / "pipe_lgbm.pkl")
models = {"xgb": pipe_xgb, "lgbm": pipe_lgbm}

df_future = df.loc[df["month"] >= "2025-01-01"].copy()
df_future["month"] = df_future["month"].dt.to_period("M").dt.to_timestamp()
print(f"πŸ“… Scenario forecast subset: {df_future.shape[0]} rows, "
      f"{df_future['region'].nunique()} regions, "
      f"months {df_future['month'].min().date()}β†’{df_future['month'].max().date()}")
βœ… Data loaded: 3328 rows Γ— 35 columns
πŸ“… Range: 2015-01-01 β†’ 2025-08-01
πŸ“… Scenario forecast subset: 208 rows, 26 regions, months 2025-01-01β†’2025-08-01
InΒ [3]:
# %% ===============================================================
# STEP 2 β€” DEFINE SCENARIOS
# ===============================================================

# === Scenario knobs (pick ONE of these dicts at a time) ===

SCENARIOS_MILD = {
    "baseline":            ("none",     0.00),
    "optimistic_gdp":      ("gdp",     +0.02),
    "pessimistic_gdp":     ("gdp",     -0.02),
    "optimistic_turnover": ("turnover", +0.03),
    "pessimistic_turnover":("turnover", -0.03),
    "policy_relaxation":   ("policy",   -0.10),
    "policy_tightening":   ("policy",   +0.10),
}

SCENARIOS_CENTRAL = {  # ← what you're using now
    "baseline":            ("none",     0.00),
    "optimistic_gdp":      ("gdp",     +0.05),
    "pessimistic_gdp":     ("gdp",     -0.05),
    "optimistic_turnover": ("turnover", +0.08),
    "pessimistic_turnover":("turnover", -0.08),
    "policy_relaxation":   ("policy",   -0.20),
    "policy_tightening":   ("policy",   +0.20),
}

SCENARIOS_STRESS = {
    "baseline":            ("none",     0.00),
    "optimistic_gdp":      ("gdp",     +0.08),
    "pessimistic_gdp":     ("gdp",     -0.08),
    "optimistic_turnover": ("turnover", +0.12),
    "pessimistic_turnover":("turnover", -0.12),
    "policy_relaxation":   ("policy",   -0.30),
    "policy_tightening":   ("policy",   +0.30),
}

# Choose the active set here:
scenarios = SCENARIOS_CENTRAL

print("βœ… Scenario configuration loaded:")
for name, (stype, sval) in scenarios.items():
    print(f"   {name:25s} β†’ {stype:10s} {sval:+.2%}")
βœ… Scenario configuration loaded:
   baseline                  β†’ none       +0.00%
   optimistic_gdp            β†’ gdp        +5.00%
   pessimistic_gdp           β†’ gdp        -5.00%
   optimistic_turnover       β†’ turnover   +8.00%
   pessimistic_turnover      β†’ turnover   -8.00%
   policy_relaxation         β†’ policy     -20.00%
   policy_tightening         β†’ policy     +20.00%
InΒ [4]:
# %% ===============================================================
# STEP 3 β€” SCENARIO SIMULATION FUNCTION
# ===============================================================

scenario_outputs = {}
for name, (shock_type, shock_value) in scenarios.items():
    df_scen = simulate_scenario(df_future, models, name, shock_type, shock_value)
    scenario_outputs[name] = df_scen
    print(f"βœ… Scenario '{name}' simulated β€” shape {df_scen.shape}")
βš™οΈ Running scenario: baseline (none, +0.00%)
βœ… Scenario simulated for xgb
βœ… Scenario simulated for lgbm
βœ… Scenario 'baseline' simulated β€” shape (208, 37)
βš™οΈ Running scenario: optimistic_gdp (gdp, +5.00%)
βœ… Scenario simulated for xgb
βœ… Scenario simulated for lgbm
βœ… Scenario 'optimistic_gdp' simulated β€” shape (208, 37)
βš™οΈ Running scenario: pessimistic_gdp (gdp, -5.00%)
βœ… Scenario simulated for xgb
βœ… Scenario simulated for lgbm
βœ… Scenario 'pessimistic_gdp' simulated β€” shape (208, 37)
βš™οΈ Running scenario: optimistic_turnover (turnover, +8.00%)
βœ… Scenario simulated for xgb
βœ… Scenario simulated for lgbm
βœ… Scenario 'optimistic_turnover' simulated β€” shape (208, 37)
βš™οΈ Running scenario: pessimistic_turnover (turnover, -8.00%)
βœ… Scenario simulated for xgb
βœ… Scenario simulated for lgbm
βœ… Scenario 'pessimistic_turnover' simulated β€” shape (208, 37)
βš™οΈ Running scenario: policy_relaxation (policy, -20.00%)
βœ… Scenario simulated for xgb
βœ… Scenario simulated for lgbm
βœ… Scenario 'policy_relaxation' simulated β€” shape (208, 37)
βš™οΈ Running scenario: policy_tightening (policy, +20.00%)
βœ… Scenario simulated for xgb
βœ… Scenario simulated for lgbm
βœ… Scenario 'policy_tightening' simulated β€” shape (208, 37)
InΒ [5]:
# %% ===============================================================
# STEP 4 β€” RUN SCENARIOS
# ===============================================================

df_baseline = scenario_outputs["baseline"].copy()
df_opt = scenario_outputs["optimistic_gdp"].copy()
df_pes = scenario_outputs["pessimistic_gdp"].copy()

for d in [df_baseline, df_opt, df_pes]:
    d["region"] = d["region"].str.strip().str.upper()
    d["month"] = pd.to_datetime(d["month"]).dt.to_period("M").dt.to_timestamp()

if "yhat_xgb_baseline" in df_baseline.columns and df_baseline["yhat_xgb"].isna().all():
    df_baseline["yhat_xgb"] = df_baseline["yhat_xgb_baseline"]
    print("βœ… Fixed baseline column: using 'yhat_xgb_baseline' values.")

df_plot = (
    df_baseline[["region", "month", "yhat_xgb"]]
    .merge(df_opt[["region", "month", "yhat_xgb_optimistic_gdp"]],
           on=["region", "month"], how="left")
    .merge(df_pes[["region", "month", "yhat_xgb_pessimistic_gdp"]],
           on=["region", "month"], how="left")
)

print(f"βœ… Merged for plotting: {df_plot.shape}")
βœ… Fixed baseline column: using 'yhat_xgb_baseline' values.
βœ… Merged for plotting: (208, 5)
InΒ [6]:
# %% ===============================================================
# STEP 5 β€” NORMALIZE AND MERGE BASELINE + SCENARIO
# ===============================================================

top_regions = (
    df_plot.groupby("region")["yhat_xgb"]
      .mean().nlargest(5).index.tolist()
)
print("[INFO] Top-5 regions by demand:", top_regions)

plot_gdp_scenarios(
    df_plot,
    countries=top_regions,
    baseline_col="yhat_xgb",
    opt_col="yhat_xgb_optimistic_gdp",
    pes_col="yhat_xgb_pessimistic_gdp",
    save_dir=FIGURES  # βœ… figures automatically saved
)
[INFO] Top-5 regions by demand: ['DE', 'FR', 'ES', 'IT', 'PL']
πŸ’Ύ Saved figure for DE β†’ ../outputs/figures/scenario_forecast_comparison_DE.png
No description has been provided for this image
πŸ’Ύ Saved figure for FR β†’ ../outputs/figures/scenario_forecast_comparison_FR.png
No description has been provided for this image
πŸ’Ύ Saved figure for ES β†’ ../outputs/figures/scenario_forecast_comparison_ES.png
No description has been provided for this image
πŸ’Ύ Saved figure for IT β†’ ../outputs/figures/scenario_forecast_comparison_IT.png
No description has been provided for this image
πŸ’Ύ Saved figure for PL β†’ ../outputs/figures/scenario_forecast_comparison_PL.png
No description has been provided for this image
InΒ [7]:
# %% ===============================================================
# STEP 6 β€” SCENARIO IMPACT ANALYSIS (% CHANGE)
# ===============================================================

impact_summary = calculate_impact(df_plot)
display(impact_summary.round(2).head(30))
optimistic_gdp_pct_diff pessimistic_gdp_pct_diff
region
LU 27.77 -2.48
LV 11.95 -1.44
NL 10.72 -1.88
HR 7.47 -3.67
SI 5.70 -3.63
CY 4.61 -4.20
DK 3.89 -0.15
SK 3.42 -3.10
MT 2.73 -5.74
EE 2.65 -2.64
IE 2.53 0.79
LT 2.46 -5.27
FI 2.12 -2.38
HU 1.61 -3.62
SE 1.26 -3.27
RO 1.08 -5.60
AT 1.02 -1.74
PL 0.98 -3.97
BE 0.79 3.70
CZ 0.75 -4.64
IT 0.26 -13.07
ES 0.16 -15.42
FR 0.00 -0.20
DE -0.04 -0.00
BG -1.66 -11.64
PT -3.70 -4.58
InΒ [8]:
# %% ===============================================================
# STEP 7 β€” GDP ELASTICITY ANALYSIS
# ===============================================================

# --- Compute GDP elasticity summary
df_sensitivity = calculate_elasticity(impact_summary, shock_size=0.10)

print("βœ… Average GDP Elasticity (approx.):")
display(df_sensitivity[["region", "gdp_elasticity"]].round(3).head(10))

print("\nπŸ”» Least sensitive:")
display(df_sensitivity[["region", "gdp_elasticity"]].round(3).tail(10))

# --- Sort for plotting
df_sensitivity = df_sensitivity.sort_values("gdp_elasticity", ascending=False)

# --- Visualization
plt.figure(figsize=(10, 6))
plt.barh(
    df_sensitivity["region"],
    df_sensitivity["gdp_elasticity"],
    color=df_sensitivity["color"],
    alpha=0.9
)
plt.axvline(1, color="mediumseagreen", linestyle="--", lw=1, label="Elasticity = 1 (High sensitivity)")
plt.axvline(0.5, color="gold", linestyle="--", lw=1, label="Elasticity = 0.5 (Moderate sensitivity)")
plt.gca().invert_yaxis()
plt.title("GDP Elasticity of Hotel Demand by Country", fontsize=13)
plt.xlabel("Elasticity (Ξ”% Demand / Ξ”% GDP)")
plt.ylabel("Region")
plt.legend(frameon=False)
plt.grid(alpha=0.3, linestyle="--")
plt.tight_layout()

# --- Save figure
elasticity_fig_path = FIGURES / "gdp_elasticity_by_country.png"
plt.savefig(elasticity_fig_path, dpi=300, bbox_inches="tight")
print(f"πŸ’Ύ Saved GDP elasticity chart β†’ {elasticity_fig_path}")

plt.show()
βœ… Average GDP Elasticity (approx.):
region gdp_elasticity
0 LU 3.024
1 LV 1.339
2 NL 1.260
3 HR 1.113
4 SI 0.933
5 CY 0.881
6 DK 0.404
7 SK 0.652
8 MT 0.847
9 EE 0.529
πŸ”» Least sensitive:
region gdp_elasticity
16 AT 0.276
17 PL 0.495
18 BE -0.291
19 CZ 0.539
20 IT 1.333
21 ES 1.559
22 FR 0.020
23 DE -0.004
24 BG 0.998
25 PT 0.089
πŸ’Ύ Saved GDP elasticity chart β†’ ../outputs/figures/gdp_elasticity_by_country.png
No description has been provided for this image

🧭 Interpretation β€” GDP Elasticity of Hotel DemandΒΆ

Scenario simulations highlight pronounced cross-country heterogeneity in how hotel demand responds to macroeconomic changes:

  • Highly elastic economies β€” Luxembourg (LU), Spain (ES), Latvia (LV), Netherlands (NL) β€” exhibit elasticity > 1, meaning hotel activity amplifies economic cycles (booms ↑↑, downturns ↓↓).
  • Moderately elastic markets β€” Slovenia (SI), Cyprus (CY), Croatia (HR) β€” show balanced sensitivity, combining cyclical responsiveness with underlying resilience.
  • Low-elasticity countries β€” Germany (DE), France (FR), Belgium (BE) β€” display stable, business-driven demand largely insulated from short-term GDP fluctuations.

Overall, smaller, tourism-intensive economies tend to experience greater volatility in hotel activity, while larger, diversified economies act as stabilizers within the European tourism system.


πŸ’‘ Policy Insight
GDP-based scenario modeling provides a framework for stress-testing tourism resilience.
Country-level elasticities help policymakers prioritize counter-cyclical support and targeted investment where macro shocks would exert the greatest impact.

Source β€” Model-based scenario simulations, forecast horizon Jan–Aug 2025.

InΒ [9]:
# %% ===============================================================
# STEP 8 β€” SAVE FINAL OUTPUTS
# ===============================================================

print("πŸ’Ύ Saving final scenario outputs...")

# 1️⃣ Save all simulated scenarios
combined = []
for name, df_scen in scenario_outputs.items():
    temp = df_scen.copy()
    temp["scenario"] = name
    combined.append(temp)

df_all_scenarios = pd.concat(combined, ignore_index=True)
df_all_scenarios.to_csv(DATA_PROCESSED / "hotel_scenario_results.csv", index=False)

# 2️⃣ Save merged GDP baseline + scenarios
df_plot.to_csv(DATA_PROCESSED / "hotel_scenario_gdp_merged.csv", index=False)

# 3️⃣ Save elasticity summary
report_dir = BASE_DIR / "outputs" / "reports"
report_dir.mkdir(parents=True, exist_ok=True)
df_sensitivity.to_csv(report_dir / "gdp_elasticity_summary.csv", index=False)

print("βœ… All scenario outputs saved successfully:")
print(f"   - Full scenario results β†’ {DATA_PROCESSED / 'hotel_scenario_results.csv'}")
print(f"   - GDP merged view β†’ {DATA_PROCESSED / 'hotel_scenario_gdp_merged.csv'}")
print(f"   - Elasticity summary β†’ {report_dir / 'gdp_elasticity_summary.csv'}")
πŸ’Ύ Saving final scenario outputs...
βœ… All scenario outputs saved successfully:
   - Full scenario results β†’ ../data/processed/hotel_scenario_results.csv
   - GDP merged view β†’ ../data/processed/hotel_scenario_gdp_merged.csv
   - Elasticity summary β†’ ../outputs/reports/gdp_elasticity_summary.csv

✨ Summary Insight¢

Tourism-demand elasticity varies sharply across Europe.
Smaller, tourism-driven economies are highly responsive to GDP shocks,
whereas larger, diversified markets remain structurally resilient.


This concludes Notebook 5, linking macroeconomic scenarios to tourism-demand sensitivity across the EU.