{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "4d4c2a4b",
   "metadata": {},
   "source": [
    "# Workspace price tier prediction\n",
    "\n",
    "LightGBM multiclass and ordinal models on engineered and out-of-fold target-encoded features, blended and tuned for macro F1."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "id": "bfcc1a55",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-10-19T18:02:45.496130Z",
     "iopub.status.busy": "2026-10-19T18:02:45.495960Z",
     "iopub.status.idle": "2026-10-19T18:02:48.784814Z",
     "shell.execute_reply": "2026-10-19T18:02:48.784327Z"
    }
   },
   "outputs": [
    {
     "data": {
      "text/plain": [
       "((279746, 60), (114538, 59))"
      ]
     },
     "execution_count": 1,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "import json\n",
    "from pathlib import Path\n",
    "\n",
    "import numpy as np\n",
    "import pandas as pd\n",
    "import lightgbm as lgb\n",
    "from sklearn.cluster import MiniBatchKMeans\n",
    "from sklearn.metrics import f1_score\n",
    "from sklearn.model_selection import StratifiedKFold\n",
    "from sklearn.neighbors import NearestNeighbors\n",
    "\n",
    "KAGGLE = Path(\"/kaggle/input\")\n",
    "DATA = next(KAGGLE.rglob(\"train.csv\")).parent if KAGGLE.exists() else Path(\"..\")\n",
    "SEED, FOLDS, K = 42, 5, 4\n",
    "\n",
    "train = pd.read_csv(DATA / \"train.csv\")\n",
    "test = pd.read_csv(DATA / \"test.csv\")\n",
    "y = train[\"price_tier\"].to_numpy() - 1\n",
    "folds = list(StratifiedKFold(FOLDS, shuffle=True, random_state=SEED).split(train, y))\n",
    "train.shape, test.shape"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6db90f65",
   "metadata": {},
   "source": [
    "## Data overview"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "id": "0f4c9e0b",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-10-19T18:02:48.786231Z",
     "iopub.status.busy": "2026-10-19T18:02:48.786141Z",
     "iopub.status.idle": "2026-10-19T18:02:49.290818Z",
     "shell.execute_reply": "2026-10-19T18:02:49.290515Z"
    }
   },
   "outputs": [
    {
     "data": {
      "image/png": "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",
      "text/plain": [
       "<Figure size 640x480 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "train[\"price_tier\"].value_counts().sort_index().plot.bar(title=\"price_tier distribution\");"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "254f8fe6",
   "metadata": {},
   "source": [
    "## Basic features"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "id": "a4925d46",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-10-19T18:02:49.292033Z",
     "iopub.status.busy": "2026-10-19T18:02:49.291960Z",
     "iopub.status.idle": "2026-10-19T18:03:24.628953Z",
     "shell.execute_reply": "2026-10-19T18:03:24.628395Z"
    }
   },
   "outputs": [
    {
     "data": {
      "text/plain": [
       "(279746, 215)"
      ]
     },
     "execution_count": 3,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "def basic_features(train, test):\n",
    "    raw = pd.concat([train.drop(columns=\"price_tier\"), test], ignore_index=True)\n",
    "    df = raw.copy()\n",
    "\n",
    "    for c in [\"operator_certified\", \"operator_has_avatar\", \"operator_id_verified\", \"bookable\"]:\n",
    "        df[c] = df[c].map({\"t\": 1, \"f\": 0})\n",
    "    for c in [\"snapshot_start_date\", \"snapshot_end_date\", \"first_review_date\", \"last_review_date\"]:\n",
    "        df[c] = (pd.to_datetime(df[c]) - pd.Timestamp(\"2000-01-01\")).dt.days\n",
    "    for c in [\"operator_ref\", \"zone\", \"borough\"]:\n",
    "        df[c] = raw[c].map(raw[c].value_counts())\n",
    "    df[\"n_facilities\"] = df[\"facilities\"].str.count(\"FCL-\")\n",
    "\n",
    "    codes = raw[\"facilities\"].fillna(\"[]\").map(json.loads).explode().dropna()\n",
    "    fac = pd.crosstab(codes.index, codes).reindex(raw.index, fill_value=0).clip(upper=1)\n",
    "    fac.columns = [\"f_\" + c[4:] for c in fac.columns]\n",
    "\n",
    "    lat, lon = raw[\"geo_lat\"], raw[\"geo_lon\"]\n",
    "    geo = pd.DataFrame({\"lat_p_lon\": lat + lon, \"lat_m_lon\": lat - lon})\n",
    "    for a in (15, 30, 60):\n",
    "        r = np.deg2rad(a)\n",
    "        geo[f\"rot{a}_x\"] = lat * np.cos(r) - lon * np.sin(r)\n",
    "        geo[f\"rot{a}_y\"] = lat * np.sin(r) + lon * np.cos(r)\n",
    "    for k in (\"metro\", \"zone\"):\n",
    "        center = raw.groupby(k)[[\"geo_lat\", \"geo_lon\"]].transform(\"median\")\n",
    "        geo[f\"dist_{k}_center\"] = np.hypot(lat - center[\"geo_lat\"], lon - center[\"geo_lon\"])\n",
    "    km = MiniBatchKMeans(100, random_state=0, n_init=3, batch_size=4096).fit(raw[[\"geo_lat\", \"geo_lon\"]])\n",
    "    geo[\"geo_cluster\"] = km.labels_\n",
    "    geo[\"geo_cluster_dist\"] = km.transform(raw[[\"geo_lat\", \"geo_lon\"]]).min(1)\n",
    "\n",
    "    df = df.drop(columns=[\"facilities\", \"space_id\"])\n",
    "    for c in df.select_dtypes(exclude=\"number\").columns:\n",
    "        df[c] = df[c].astype(\"category\")\n",
    "    X = pd.concat([df, fac, geo], axis=1)\n",
    "    return X.iloc[: len(train)].reset_index(drop=True), X.iloc[len(train):].reset_index(drop=True)\n",
    "\n",
    "\n",
    "X_train, X_test = basic_features(train, test)\n",
    "X_train.shape"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9e0fdb7e",
   "metadata": {},
   "source": [
    "## Out-of-fold target features"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "id": "e2d628de",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-10-19T18:03:24.630307Z",
     "iopub.status.busy": "2026-10-19T18:03:24.630218Z",
     "iopub.status.idle": "2026-10-19T18:03:28.048686Z",
     "shell.execute_reply": "2026-10-19T18:03:28.048320Z"
    }
   },
   "outputs": [
    {
     "data": {
      "text/plain": [
       "(279746, 350)"
      ]
     },
     "execution_count": 4,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "TE_GROUPS = {\n",
    "    \"op\": [\"operator_ref\"], \"zone\": [\"zone\"], \"boro\": [\"borough\"], \"sc\": [\"space_class\"],\n",
    "    \"metro_sc\": [\"metro\", \"space_class\"], \"zone_acc\": [\"zone\", \"access_type\"],\n",
    "    \"zone_seats\": [\"zone\", \"max_seats\"], \"op_zone\": [\"operator_ref\", \"zone\"],\n",
    "    \"zone_sc\": [\"zone\", \"space_class\"], \"op_sc\": [\"operator_ref\", \"space_class\"],\n",
    "    \"op_seats\": [\"operator_ref\", \"max_seats\"], \"geo\": [\"geo_lat\", \"geo_lon\"],\n",
    "    \"dup\": [\"operator_ref\", \"zone\", \"space_class\", \"max_seats\"],\n",
    "    \"dup2\": [\"operator_ref\", \"space_class\", \"max_seats\", \"access_type\"],\n",
    "    \"metro_acc_seats\": [\"metro\", \"access_type\", \"max_seats\"],\n",
    "}\n",
    "RES_GROUPS = [\"op\", \"zone\", \"geo\", \"op_zone\", \"zone_sc\", \"sc\", \"op_sc\"]\n",
    "GEO_KS = (1, 5, 20, 50)\n",
    "\n",
    "both = pd.concat([train, test], ignore_index=True)\n",
    "group_ids = {name: both[cols].astype(str).groupby(cols, sort=False, dropna=False).ngroup().to_numpy()\n",
    "             for name, cols in TE_GROUPS.items()}\n",
    "base_ids = both[[\"access_type\", \"max_seats\"]].astype(str).groupby([\"access_type\", \"max_seats\"], dropna=False).ngroup().to_numpy()\n",
    "coords = both[[\"geo_lat\", \"geo_lon\"]].to_numpy()\n",
    "prior = np.bincount(y, minlength=K) / len(y)\n",
    "tiers = np.arange(1, K + 1)\n",
    "\n",
    "\n",
    "def target_encoding(fit, apply):\n",
    "    cols = {}\n",
    "    for name, g in group_ids.items():\n",
    "        n = g.max() + 1\n",
    "        counts = np.zeros((n, K))\n",
    "        np.add.at(counts, (g[fit], y[fit]), 1)\n",
    "        total = counts.sum(1)\n",
    "        p = ((counts + 20 * prior) / (total[:, None] + 20))[g[apply]]\n",
    "        for c in range(K):\n",
    "            cols[f\"te_{name}_p{c + 1}\"] = p[:, c]\n",
    "        cols[f\"te_{name}_mean\"] = p @ tiers\n",
    "        cols[f\"te_{name}_n\"] = np.log1p(total[g[apply]])\n",
    "    return cols\n",
    "\n",
    "\n",
    "def residual_encoding(fit, apply):\n",
    "    yt = y[fit] + 1.0\n",
    "    nb = base_ids.max() + 1\n",
    "    base_mean = (np.bincount(base_ids[fit], yt, nb) + 20 * yt.mean()) / (np.bincount(base_ids[fit], minlength=nb) + 20)\n",
    "    resid = yt - base_mean[base_ids[fit]]\n",
    "    cols = {}\n",
    "    for name in RES_GROUPS:\n",
    "        g = group_ids[name]\n",
    "        n = g.max() + 1\n",
    "        cnt = np.bincount(g[fit], minlength=n)[g[apply]] + 10\n",
    "        mean = np.bincount(g[fit], resid, n)[g[apply]] / cnt\n",
    "        cols[f\"res_{name}_mean\"] = mean\n",
    "        cols[f\"res_{name}_rms\"] = np.sqrt(np.bincount(g[fit], resid ** 2, n)[g[apply]] / cnt)\n",
    "        cols[f\"res_{name}_pred\"] = base_mean[base_ids[apply]] + mean\n",
    "    return cols\n",
    "\n",
    "\n",
    "def geo_neighbours(fit, apply):\n",
    "    points, inv = np.unique(coords[fit], axis=0, return_inverse=True)\n",
    "    counts = np.zeros((len(points), K))\n",
    "    np.add.at(counts, (inv.ravel(), y[fit]), 1)\n",
    "    dist, idx = NearestNeighbors(n_neighbors=max(GEO_KS)).fit(points).kneighbors(coords[apply])\n",
    "    cols = {}\n",
    "    for k in GEO_KS:\n",
    "        c = counts[idx[:, :k]].sum(1)\n",
    "        p = (c + 5 * prior) / (c.sum(1)[:, None] + 5)\n",
    "        for j in range(K):\n",
    "            cols[f\"geo{k}_p{j + 1}\"] = p[:, j]\n",
    "        cols[f\"geo{k}_mean\"] = p @ tiers\n",
    "        cols[f\"geo{k}_dist\"] = dist[:, k - 1]\n",
    "    return cols\n",
    "\n",
    "\n",
    "def target_features(fit, apply):\n",
    "    return pd.DataFrame({**target_encoding(fit, apply), **residual_encoding(fit, apply), **geo_neighbours(fit, apply)})\n",
    "\n",
    "\n",
    "T_train = pd.concat([target_features(fit, val).set_index(val) for fit, val in folds]).sort_index()\n",
    "T_test = target_features(np.arange(len(train)), np.arange(len(train), len(both)))\n",
    "\n",
    "X_train = pd.concat([X_train, T_train], axis=1)\n",
    "X_test = pd.concat([X_test, T_test], axis=1)\n",
    "X_train.shape"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "f266b485",
   "metadata": {},
   "source": [
    "## Models"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "id": "ec934e52",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-10-19T18:03:28.050010Z",
     "iopub.status.busy": "2026-10-19T18:03:28.049925Z",
     "iopub.status.idle": "2026-10-19T18:22:11.736232Z",
     "shell.execute_reply": "2026-10-19T18:22:11.735823Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "fold 0 {'multiclass': 0.7523, 'ordinal': 0.7557}\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "fold 1 {'multiclass': 0.753, 'ordinal': 0.7551}\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "fold 2 {'multiclass': 0.7515, 'ordinal': 0.7517}\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "fold 3 {'multiclass': 0.7509, 'ordinal': 0.7541}\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "fold 4 {'multiclass': 0.7522, 'ordinal': 0.757}\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "{'multiclass': 0.752, 'ordinal': 0.7547}"
      ]
     },
     "execution_count": 5,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "PARAMS = dict(learning_rate=0.05, num_leaves=127, min_child_samples=20, feature_fraction=0.8,\n",
    "              bagging_fraction=0.8, bagging_freq=1, verbose=-1, seed=SEED)\n",
    "\n",
    "\n",
    "def fit_predict(params, X_fit, y_fit, X_val, y_val, X_test):\n",
    "    model = lgb.train(params, lgb.Dataset(X_fit, y_fit), 5000,\n",
    "                      valid_sets=[lgb.Dataset(X_val, y_val)],\n",
    "                      callbacks=[lgb.early_stopping(100, verbose=False)])\n",
    "    return model.predict(X_val, num_iteration=model.best_iteration), model.predict(X_test, num_iteration=model.best_iteration)\n",
    "\n",
    "\n",
    "def cumulative_to_classes(p_above):\n",
    "    p_above = np.minimum.accumulate(p_above, axis=1)\n",
    "    edges = np.column_stack([np.ones(len(p_above)), p_above, np.zeros(len(p_above))])\n",
    "    return np.clip(edges[:, :-1] - edges[:, 1:], 1e-6, 1)\n",
    "\n",
    "\n",
    "oof = {m: np.zeros((len(train), K)) for m in [\"multiclass\", \"ordinal\"]}\n",
    "pred = {m: np.zeros((len(test), K)) for m in [\"multiclass\", \"ordinal\"]}\n",
    "\n",
    "for i, (fit, val) in enumerate(folds):\n",
    "    Xf, Xv = X_train.iloc[fit], X_train.iloc[val]\n",
    "\n",
    "    p_val, p_test = fit_predict({**PARAMS, \"objective\": \"multiclass\", \"num_class\": K},\n",
    "                                Xf, y[fit], Xv, y[val], X_test)\n",
    "    oof[\"multiclass\"][val], pred[\"multiclass\"] = p_val, pred[\"multiclass\"] + p_test / FOLDS\n",
    "\n",
    "    above_val, above_test = [], []\n",
    "    for k in range(K - 1):\n",
    "        v, t = fit_predict({**PARAMS, \"objective\": \"binary\"}, Xf, y[fit] > k, Xv, y[val] > k, X_test)\n",
    "        above_val.append(v)\n",
    "        above_test.append(t)\n",
    "    oof[\"ordinal\"][val] = cumulative_to_classes(np.column_stack(above_val))\n",
    "    pred[\"ordinal\"] += cumulative_to_classes(np.column_stack(above_test)) / FOLDS\n",
    "\n",
    "    print(f\"fold {i}\", {m: round(f1_score(y[val], oof[m][val].argmax(1), average=\"macro\"), 4) for m in oof})\n",
    "\n",
    "{m: round(f1_score(y, p.argmax(1), average=\"macro\"), 4) for m, p in oof.items()}"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4dd9340d",
   "metadata": {},
   "source": [
    "## Blend and class weights"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "id": "dc3ba833",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-10-19T18:22:11.737437Z",
     "iopub.status.busy": "2026-10-19T18:22:11.737358Z",
     "iopub.status.idle": "2026-10-19T18:22:15.367684Z",
     "shell.execute_reply": "2026-10-19T18:22:15.367228Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "blend CV macro F1: 0.7559\n",
      "with class weights: 0.7577 [1.    0.98  1.15  0.852]\n"
     ]
    }
   ],
   "source": [
    "def macro_f1(p, w):\n",
    "    return f1_score(y, (p * w).argmax(1), average=\"macro\")\n",
    "\n",
    "\n",
    "def tune_class_weights(p, rounds=3):\n",
    "    w = np.ones(K)\n",
    "    for _ in range(rounds):\n",
    "        for k in range(1, K):\n",
    "            grid = w[k] * np.exp(np.linspace(-0.3, 0.3, 31))\n",
    "            scores = [macro_f1(p, np.r_[w[:k], g, w[k + 1:]]) for g in grid]\n",
    "            w[k] = grid[int(np.argmax(scores))]\n",
    "    return w\n",
    "\n",
    "\n",
    "blend_oof = (oof[\"multiclass\"] + oof[\"ordinal\"]) / 2\n",
    "blend_test = (pred[\"multiclass\"] + pred[\"ordinal\"]) / 2\n",
    "weights = tune_class_weights(blend_oof)\n",
    "\n",
    "print(\"blend CV macro F1:\", round(macro_f1(blend_oof, np.ones(K)), 4))\n",
    "print(\"with class weights:\", round(macro_f1(blend_oof, weights), 4), weights.round(3))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "38da4de5",
   "metadata": {},
   "source": [
    "## Submission"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "id": "dcdfed8a",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-10-19T18:22:15.368910Z",
     "iopub.status.busy": "2026-10-19T18:22:15.368842Z",
     "iopub.status.idle": "2026-10-19T18:22:15.395430Z",
     "shell.execute_reply": "2026-10-19T18:22:15.395035Z"
    }
   },
   "outputs": [
    {
     "data": {
      "text/plain": [
       "price_tier\n",
       "1    25359\n",
       "2    37553\n",
       "3    27169\n",
       "4    24457\n",
       "Name: count, dtype: int64"
      ]
     },
     "execution_count": 7,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "submission = pd.DataFrame({\"ID\": test[\"space_id\"], \"price_tier\": (blend_test * weights).argmax(1) + 1})\n",
    "submission.to_csv(\"submission.csv\", index=False)\n",
    "submission[\"price_tier\"].value_counts().sort_index()"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.13.9"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
