{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Predicting Hotel Booking Cancellations with Explainable Machine Learning\n",
    "### Analysis notebook v2 \u2014 aligned to the paper's Section 3 (per-hotel models)\n",
    "\n",
    "Run order: Runtime \u2192 Run all. When the data cell asks, upload `hotel_bookings.csv`.\n",
    "\n",
    "Design implemented here (exactly as written in the paper):\n",
    "- One model set **per hotel** (Resort Hotel, City Hotel), reported side by side\n",
    "- Predictors restricted to booking-time information; `reservation_status`, `reservation_status_date`, `assigned_room_type` excluded as post-outcome\n",
    "- **Main specification excludes `country`** (following Yang & Miao, 2024) and excludes `agent` / `company` identifiers; a **sensitivity run with country included** is reported at the end\n",
    "- Out-of-time split **within each hotel**: earliest 75% of arrivals = training, latest 25% = test\n",
    "- Operating threshold chosen **inside the training data only** (out-of-fold predictions), under FN:FP cost ratios 2x, 3x, 5x\n",
    "- SHAP computed on the **test period** for the best model of each hotel (global + market-segment level)\n",
    "- Predicted probabilities summed by arrival date \u2192 expected cancellations, net demand, overbooking buffer\n"
   ]
  },
  {
   "cell_type": "code",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "# ---------- Cell 1: Setup ----------\n",
    "import warnings; warnings.filterwarnings(\"ignore\")\n",
    "from pathlib import Path\n",
    "import numpy as np, pandas as pd\n",
    "import matplotlib; matplotlib.use(\"Agg\")\n",
    "import matplotlib.pyplot as plt\n",
    "from google.colab import files as colab_files\n",
    "\n",
    "OUT = Path(\"outputs\"); OUT.mkdir(exist_ok=True)\n",
    "print(\"Setup done.\")\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Step 1 \u2014 Load the data"
   ]
  },
  {
   "cell_type": "code",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "# ---------- Cell 2: Load data ----------\n",
    "CSV = Path(\"hotel_bookings.csv\")\n",
    "if not CSV.exists():\n",
    "    print(\"Please upload hotel_bookings.csv ...\")\n",
    "    up = colab_files.upload()\n",
    "    CSV = Path(list(up.keys())[0])\n",
    "df = pd.read_csv(CSV)\n",
    "MONTHS = {m:i+1 for i,m in enumerate([\"January\",\"February\",\"March\",\"April\",\"May\",\"June\",\"July\",\"August\",\"September\",\"October\",\"November\",\"December\"])}\n",
    "df[\"arrival_date\"] = pd.to_datetime(dict(year=df.reservation_status_date.str.slice(0,4).astype(int)*0 + df.arrival_date_year, month=df.arrival_date_month.map(MONTHS), day=df.arrival_date_day_of_month))\n",
    "print(\"Shape:\", df.shape)\n",
    "print(\"Hotels:\", df.hotel.value_counts().to_dict())\n",
    "print(\"Overall cancellation rate:\", round(df.is_canceled.mean(),4))\n",
    "for h,g in df.groupby(\"hotel\"):\n",
    "    print(h, \"n =\", len(g), \"| cancel rate:\", round(g.is_canceled.mean(),4))\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Step 2 \u2014 Leakage control and predictor set (Section 3.2)"
   ]
  },
  {
   "cell_type": "code",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "# ---------- Cell 3: Predictor set ----------\n",
    "DROP_ALWAYS = [\"reservation_status\",\"reservation_status_date\",\"assigned_room_type\",\"arrival_date_year\",\"arrival_date_month\",\"arrival_date_day_of_month\",\"arrival_date_week_number\"]\n",
    "DROP_MAIN   = [\"country\",\"agent\",\"company\"]   # main specification (Section 3.2)\n",
    "TARGET, DATE, HOTEL = \"is_canceled\", \"arrival_date\", \"hotel\"\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Step 3 \u2014 Per-hotel estimation: split, tune-free pipelines, train-only threshold (Sections 3.2\u20133.4)"
   ]
  },
  {
   "cell_type": "code",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "# ---------- Cell 4: Per-hotel modelling ----------\n",
    "from sklearn.compose import ColumnTransformer\n",
    "from sklearn.pipeline import Pipeline\n",
    "from sklearn.impute import SimpleImputer\n",
    "from sklearn.preprocessing import OneHotEncoder, StandardScaler\n",
    "from sklearn.linear_model import LogisticRegression\n",
    "from sklearn.ensemble import RandomForestClassifier\n",
    "from sklearn.model_selection import cross_val_predict\n",
    "from sklearn.metrics import roc_auc_score, average_precision_score, brier_score_loss, confusion_matrix\n",
    "from xgboost import XGBClassifier\n",
    "\n",
    "COST_RATIOS = [2.0, 3.0, 5.0]\n",
    "def best_threshold(y, p, fn_cost):\n",
    "    grid = np.arange(0.05, 0.96, 0.01); best, bt = np.inf, 0.5\n",
    "    for t in grid:\n",
    "        tn, fp, fn, tp = confusion_matrix(y, (p >= t).astype(int)).ravel()\n",
    "        c = fn_cost * fn + fp\n",
    "        if c < best: best, bt = c, float(t)\n",
    "    return bt\n",
    "\n",
    "def run_hotel(data, extra_drop):\n",
    "    data = data.sort_values(DATE).reset_index(drop=True)\n",
    "    feats = [c for c in data.columns if c not in DROP_ALWAYS + extra_drop + [TARGET, DATE, HOTEL]]\n",
    "    k = int(0.75 * len(data))\n",
    "    tr, te = data.iloc[:k], data.iloc[k:]\n",
    "    ytr, yte = tr[TARGET].values, te[TARGET].values\n",
    "    num = [c for c in feats if data[c].dtype != object]\n",
    "    cat = [c for c in feats if data[c].dtype == object]\n",
    "    pre = ColumnTransformer([\n",
    "        (\"num\", Pipeline([(\"imp\", SimpleImputer(strategy=\"median\")), (\"sc\", StandardScaler())]), num),\n",
    "        (\"cat\", Pipeline([(\"imp\", SimpleImputer(strategy=\"most_frequent\")), (\"oh\", OneHotEncoder(handle_unknown=\"ignore\"))]), cat)])\n",
    "    models = {\n",
    "        \"Logistic regression\": LogisticRegression(max_iter=1000, class_weight=\"balanced\"),\n",
    "        \"Random forest\": RandomForestClassifier(n_estimators=400, n_jobs=-1, random_state=42),\n",
    "        \"XGBoost\": XGBClassifier(n_estimators=500, max_depth=6, learning_rate=0.05, subsample=0.9, colsample_bytree=0.9, eval_metric=\"logloss\", n_jobs=-1, random_state=42)}\n",
    "    rows, store = [], {}\n",
    "    for name, mdl in models.items():\n",
    "        pipe = Pipeline([(\"pre\", pre), (\"mdl\", mdl)])\n",
    "        # threshold inside training data only: out-of-fold probabilities on the training period\n",
    "        oof = cross_val_predict(Pipeline([(\"pre\", pre), (\"mdl\", mdl)]), tr[feats], ytr, cv=3, method=\"predict_proba\")[:,1]\n",
    "        ths = {r: best_threshold(ytr, oof, r) for r in COST_RATIOS}\n",
    "        pipe.fit(tr[feats], ytr)\n",
    "        p = pipe.predict_proba(te[feats])[:,1]\n",
    "        pred = (p >= 0.5).astype(int)\n",
    "        tn, fp, fn, tp = confusion_matrix(yte, pred).ravel()\n",
    "        rows.append({\"Model\": name, \"ROC-AUC\": roc_auc_score(yte,p), \"PR-AUC\": average_precision_score(yte,p),\n",
    "                     \"Brier\": brier_score_loss(yte,p), \"Accuracy@0.5\": (pred==yte).mean(),\n",
    "                     \"Precision@0.5\": tp/(tp+fp), \"Recall@0.5\": tp/(tp+fn), \"F1@0.5\": 2*tp/(2*tp+fp+fn),\n",
    "                     \"Thr (FN 2x)\": ths[2.0], \"Thr (FN 3x)\": ths[3.0], \"Thr (FN 5x)\": ths[5.0]})\n",
    "        store[name] = (pipe, p)\n",
    "    res = pd.DataFrame(rows).round(4)\n",
    "    return res, store, tr, te, feats\n",
    "\n",
    "results_by_hotel, store_by_hotel, split_info = {}, {}, {}\n",
    "for h, g in df.groupby(HOTEL):\n",
    "    res, store, tr, te, feats = run_hotel(g, DROP_MAIN)\n",
    "    results_by_hotel[h] = res; store_by_hotel[h] = store\n",
    "    split_info[h] = (len(tr), len(te), tr[TARGET].mean(), te[TARGET].mean(), feats)\n",
    "    print(f\"\\n===== {h} =====  train n={len(tr)} (cancel {tr[TARGET].mean():.3f}) | test n={len(te)} (cancel {te[TARGET].mean():.3f}) | predictors={len(feats)}\")\n",
    "    print(res.to_string(index=False))\n",
    "    res.to_csv(OUT / f\"results_{h.replace(' ','_')}.csv\", index=False)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Step 4 \u2014 Sensitivity: country included (Section 3.2)"
   ]
  },
  {
   "cell_type": "code",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "# ---------- Cell 5: Sensitivity with country ----------\n",
    "sens = []\n",
    "for h, g in df.groupby(HOTEL):\n",
    "    res_c, _, _, _, _ = run_hotel(g, [\"agent\",\"company\"])   # country kept\n",
    "    base = results_by_hotel[h].set_index(\"Model\")[\"ROC-AUC\"]\n",
    "    for name in base.index:\n",
    "        sens.append({\"Hotel\": h, \"Model\": name, \"AUC main (no country)\": base[name], \"AUC with country\": res_c.set_index(\"Model\").loc[name,\"ROC-AUC\"]})\n",
    "sens = pd.DataFrame(sens).round(4)\n",
    "print(sens.to_string(index=False))\n",
    "sens.to_csv(OUT / \"sensitivity_country.csv\", index=False)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Step 5 \u2014 SHAP on the test period, best model per hotel (Section 3.5)"
   ]
  },
  {
   "cell_type": "code",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "# ---------- Cell 6: SHAP per hotel ----------\n",
    "import shap\n",
    "ACTIONABLE = {\"deposit_type\",\"market_segment\",\"distribution_channel\",\"total_of_special_requests\",\"booking_changes\",\"meal\"}\n",
    "for h in results_by_hotel:\n",
    "    best = results_by_hotel[h].iloc[results_by_hotel[h][\"ROC-AUC\"].argmax()][\"Model\"]\n",
    "    pipe, p = store_by_hotel[h][best]\n",
    "    _, _, tr, te, feats = (None, None, None, None, None)\n",
    "    # rebuild the hotel's test frame in the same order as the split\n",
    "    g = df[df[HOTEL]==h].sort_values(DATE).reset_index(drop=True)\n",
    "    k = int(0.75*len(g)); te = g.iloc[k:]\n",
    "    Xte = pipe.named_steps[\"pre\"].transform(te[feats])\n",
    "    try: feat_names = pipe.named_steps[\"pre\"].get_feature_names_out()\n",
    "    except Exception: feat_names = np.array([f\"f{i}\" for i in range(Xte.shape[1])])\n",
    "    expl = shap.TreeExplainer(pipe.named_steps[\"mdl\"]) if best != \"Logistic regression\" else shap.LinearExplainer(pipe.named_steps[\"mdl\"], Xte)\n",
    "    sv = expl.shap_values(Xte)\n",
    "    if isinstance(sv, list): sv = sv[1]\n",
    "    sv = np.asarray(sv)\n",
    "    if sv.ndim == 3: sv = sv[:, :, 1]\n",
    "    imp = pd.DataFrame({\"feature\": feat_names, \"mean_abs_shap\": np.abs(sv).mean(0)}).sort_values(\"mean_abs_shap\", ascending=False)\n",
    "    base_of = lambda f: f.split(\"__\",1)[-1].rsplit(\"_\",1)[0] if \"__\" in f else f\n",
    "    imp[\"base_feature\"] = imp[\"feature\"].map(lambda f: f.split(\"__\",1)[-1])\n",
    "    imp_base = imp.groupby(\"base_feature\")[\"mean_abs_shap\"].sum().reset_index().sort_values(\"mean_abs_shap\", ascending=False)\n",
    "    imp_base[\"actionable\"] = imp_base[\"base_feature\"].isin(ACTIONABLE)\n",
    "    print(f\"\\n===== SHAP {h} \u2014 best model: {best} =====\")\n",
    "    print(imp_base.head(15).round(4).to_string(index=False))\n",
    "    tot = imp_base[\"mean_abs_shap\"].sum()\n",
    "    print(\"Actionable share of total SHAP importance:\", round(imp_base.loc[imp_base.actionable,\"mean_abs_shap\"].sum()/tot, 3))\n",
    "    imp_base.to_csv(OUT / f\"shap_global_{h.replace(' ','_')}.csv\", index=False)\n",
    "    # segment level: market_segment\n",
    "    seg_rows = []\n",
    "    seg_vals = te[\"market_segment\"].values\n",
    "    for seg in sorted(set(seg_vals)):\n",
    "        m = seg_vals == seg\n",
    "        if m.sum() >= 30:\n",
    "            s = pd.Series(np.abs(sv[m]).mean(0), index=feat_names)\n",
    "            s_base = s.groupby([base_of(f) if False else f.split(\"__\",1)[-1] for f in s.index]).sum()\n",
    "            seg_rows.append(pd.Series(s_base, name=seg))\n",
    "    segtab = pd.DataFrame(seg_rows).T\n",
    "    print(segtab.round(4).head(15).to_string())\n",
    "    segtab.to_csv(OUT / f\"shap_by_segment_{h.replace(' ','_')}.csv\")\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Step 6 \u2014 From probabilities to revenue decisions, per hotel (Section 3.6)"
   ]
  },
  {
   "cell_type": "code",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "# ---------- Cell 7: Net demand + buffer per hotel ----------\n",
    "for h in results_by_hotel:\n",
    "    best = results_by_hotel[h].iloc[results_by_hotel[h][\"ROC-AUC\"].argmax()][\"Model\"]\n",
    "    pipe, p = store_by_hotel[h][best]\n",
    "    thr = float(results_by_hotel[h].set_index(\"Model\").loc[best, \"Thr (FN 3x)\"])\n",
    "    g = df[df[HOTEL]==h].sort_values(DATE).reset_index(drop=True)\n",
    "    k = int(0.75*len(g)); te = g.iloc[k:].copy()\n",
    "    te[\"p_cancel\"] = p; te[\"actual_cancel\"] = te[TARGET].values\n",
    "    daily = te.groupby(DATE).agg(bookings=(\"p_cancel\",\"size\"), expected_cancellations=(\"p_cancel\",\"sum\"), actual_cancellations=(\"actual_cancel\",\"sum\")).reset_index()\n",
    "    daily[\"net_expected_demand\"] = daily[\"bookings\"] - daily[\"expected_cancellations\"]\n",
    "    daily[\"buffer_at_expected\"] = daily[\"expected_cancellations\"].round().astype(int)\n",
    "    daily.to_csv(OUT / f\"daily_net_demand_{h.replace(' ','_')}.csv\", index=False)\n",
    "    print(f\"\\n===== {h} \u2014 best: {best}, threshold (FN 3x, from training): {thr} =====\")\n",
    "    print(\"test days:\", len(daily), \"| bookings:\", int(daily.bookings.sum()), \"| expected canc:\", round(daily.expected_cancellations.sum(),1), \"| actual canc:\", int(daily.actual_cancellations.sum()))\n",
    "    print(daily.head(10).round(2).to_string(index=False))\n",
    "    plt.figure(figsize=(11,4))\n",
    "    plt.plot(daily[DATE], daily[\"bookings\"], label=\"Bookings\", lw=1)\n",
    "    plt.plot(daily[DATE], daily[\"net_expected_demand\"], label=\"Net expected demand\", lw=1)\n",
    "    plt.plot(daily[DATE], daily[\"bookings\"]-daily[\"actual_cancellations\"], label=\"Actual arrivals\", lw=1, alpha=.7)\n",
    "    plt.legend(); plt.title(f\"Daily demand translation \u2014 {h} ({best})\"); plt.tight_layout()\n",
    "    plt.savefig(OUT / f\"fig_daily_net_demand_{h.replace(' ','_')}.png\", dpi=200); plt.close()\n",
    "print(\"\\nAll v2 outputs written to outputs/\")\n"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "version": "3.10"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}