π 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ΒΆ
- Environment Setup
- Load Baseline Data and Trained Models
- Define Macroeconomic Scenarios
- Simulate Forecasts for Each Scenario
- Merge Baseline and Scenario Results
- Visualize Scenario Comparisons (GDP Shocks)
- Compute % Deviations vs Baseline
- Estimate GDP Elasticity Across EU Countries
- 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.
# %% ===============================================================
# 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.
# %% ===============================================================
# 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
# %% ===============================================================
# 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%
# %% ===============================================================
# 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)
# %% ===============================================================
# 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)
# %% ===============================================================
# 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
πΎ Saved figure for FR β ../outputs/figures/scenario_forecast_comparison_FR.png
πΎ Saved figure for ES β ../outputs/figures/scenario_forecast_comparison_ES.png
πΎ Saved figure for IT β ../outputs/figures/scenario_forecast_comparison_IT.png
πΎ Saved figure for PL β ../outputs/figures/scenario_forecast_comparison_PL.png
# %% ===============================================================
# 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 |
# %% ===============================================================
# 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
π§ 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.
# %% ===============================================================
# 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.