{ "cells": [ { "cell_type": "markdown", "id": "0", "metadata": {}, "source": [ "---\n", "title: \"Chapter 31: Classical ML with scikit-learn\"\n", "---" ] }, { "cell_type": "markdown", "id": "1", "metadata": {}, "source": [ "[](https://colab.research.google.com/github/sambaiga/ds-mlops-path/blob/main/tutorials/04-ml-intro/31-sklearn-core.ipynb) [](https://raw.githubusercontent.com/sambaiga/ds-mlops-path/main/tutorials/04-ml-intro/31-sklearn-core.ipynb)" ] }, { "cell_type": "markdown", "id": "2", "metadata": {}, "source": [ "Half of the bookings that end up cancelled were made 113 days before check-in. Half of the ones that stick were made 47 days out. Somewhere between a reservation landing in the system and a guest walking through the door, more than a third of all 117,429 bookings in this hotel group's three years of records fall through, and it happens far more at the City Hotel (42%) than at the Resort (28%). None of that is a guess. It's sitting in a CSV, waiting for someone to ask two questions of it: which of today's bookings will actually show up, and what should a room like this one have cost in the first place." ] }, { "cell_type": "markdown", "id": "3", "metadata": {}, "source": [ "Chapter 30 gave you the vocabulary to ask those questions properly: a spec card, a workflow, a way to reason about splits and about bias and variance before writing a line of code. Everything in that chapter was diagrams and definitions. This is where it becomes code you run yourself, on the two questions above, using scikit-learn, the tool nearly every classical model in this book runs through. Learn the shape every one of its algorithms takes and you can read unfamiliar sklearn code on sight, swap one model for another without touching your preprocessing, and follow how [Chapter 32](32-sklearn-pipeline.ipynb) chains today's separate steps into a single object." ] }, { "cell_type": "markdown", "id": "4", "metadata": {}, "source": [ "::: {.callout-note collapse=\"true\" icon=false}\n", "## Learning objectives\n", "\n", "By the end of this chapter you will be able to:\n", "\n", "| # | Skill | Covered in |\n", "|---|---|---|\n", "| 1 | Distinguish scikit-learn's Transformer and Predictor interfaces, and know which one any given algorithm implements | Sec. 2 |\n", "| 2 | Split data before fitting a scaler or a model, and explain why the opposite order causes leakage | Sec. 3 |\n", "| 3 | Establish a dummy baseline before judging whether a real model has learned anything | Sec. 4 |\n", "| 4 | Fit and evaluate LinearRegression and LogisticRegression, including class_weight for imbalanced targets | Sec. 5 |\n", "| 5 | Use cross-validation to get a steadier performance estimate than a single train/test split | Sec. 6 |\n", "| 6 | Read a learning curve to diagnose whether more data or better features would move a metric | Sec. 7 |\n", "| 7 | Bundle a fitted scaler and model together so they can't be loaded out of sync in production | Sec. 8 |\n", ":::" ] }, { "cell_type": "markdown", "id": "5", "metadata": {}, "source": [ "## 1. Two questions, one dataset\n", "\n", "Both questions point at the same table: 117,429 bookings from two Portuguese hotels, one row per reservation. `adr`, average daily rate, is what the room actually sold for, in euros. `is_canceled` is 1 if the booking fell through before check-in, 0 if the guest showed up. Predicting the first is regression; predicting the second is binary classification. Same data, two different kinds of answer." ] }, { "cell_type": "code", "execution_count": null, "id": "6", "metadata": {}, "outputs": [], "source": [ "from pathlib import Path\n", "from typing import Any\n", "\n", "from great_tables import GT, md as gt_md\n", "import joblib\n", "from lets_plot import (\n", " LetsPlot,\n", " aes,\n", " as_discrete,\n", " geom_bar,\n", " geom_hline,\n", " geom_line,\n", " geom_point,\n", " geom_ribbon,\n", " ggplot,\n", " labs,\n", " scale_color_manual,\n", " scale_fill_manual,\n", ")\n", "from loguru import logger\n", "import numpy as np\n", "import pandas as pd\n", "from sklearn.dummy import DummyClassifier, DummyRegressor\n", "from sklearn.linear_model import LinearRegression, LogisticRegression\n", "from sklearn.metrics import (\n", " classification_report,\n", " f1_score,\n", " mean_absolute_error,\n", " mean_squared_error,\n", " r2_score,\n", ")\n", "from sklearn.model_selection import (\n", " KFold,\n", " StratifiedKFold,\n", " cross_val_score,\n", " learning_curve,\n", " train_test_split,\n", ")\n", "from sklearn.pipeline import Pipeline\n", "from sklearn.preprocessing import MinMaxScaler, StandardScaler\n", "\n", "from ark.plot.gt_style import metrics_report, themed_gt\n", "from ark.plot.theme import modern_theme\n", "from ark.plot.tokens import DANGER, INFO, WARNING\n", "\n", "LetsPlot.setup_html()" ] }, { "cell_type": "markdown", "id": "7", "metadata": {}, "source": [ "A typed loader keeps the download-and-cache logic out of the way of everything that follows: it fetches the CSV once, caches it to disk, and every later run reads the local copy instead of hitting the network again." ] }, { "cell_type": "code", "execution_count": null, "id": "8", "metadata": {}, "outputs": [], "source": [ "DATA_URL: str = \"https://raw.githubusercontent.com/rfordatascience/tidytuesday/master/data/2020/2020-02-11/hotels.csv\"\n", "DATA_PATH: Path = Path(\"data/hotel_bookings.csv\")\n", "MODEL_DIR: Path = Path(\"models\")\n", "\n", "\n", "def load_hotel_data(path: Path = DATA_PATH, url: str = DATA_URL) -> pd.DataFrame:\n", " \"\"\"Load hotel bookings, downloading from url on first call.\"\"\"\n", " if not path.exists():\n", " path.parent.mkdir(parents=True, exist_ok=True)\n", " raw = pd.read_csv(url)\n", " raw.to_csv(path, index=False)\n", " logger.success(f\"Downloaded {len(raw):,} rows to {path}\")\n", " else:\n", " raw = pd.read_csv(path)\n", " logger.info(f\"Loaded {len(raw):,} rows from cache: {path}\")\n", " df = raw[(raw[\"adr\"] > 0) & (raw[\"adr\"] <= 1000)].reset_index(drop=True)\n", " logger.info(f\"After cleaning: {len(df):,} rows\")\n", " return df" ] }, { "cell_type": "code", "execution_count": null, "id": "9", "metadata": {}, "outputs": [], "source": [ "df: pd.DataFrame = load_hotel_data()\n", "logger.info(f\"Shape: {df.shape[0]:,} rows x {df.shape[1]} columns\")\n", "logger.info(f\"Overall cancellation rate: {df['is_canceled'].mean():.1%}\")" ] }, { "cell_type": "markdown", "id": "10", "metadata": {}, "source": [ "| Target | Column | Task |\n", "|---|---|---|\n", "| Room price | `adr` | Regression |\n", "| Booking cancelled | `is_canceled` | Binary classification |\n", "\n", "Look at the shape of each target before building anything on top of it. A summary table gets you the numeric columns at a glance:" ] }, { "cell_type": "code", "execution_count": null, "id": "11", "metadata": {}, "outputs": [], "source": [ "stats: pd.DataFrame = df.describe().round(2).T.reset_index().rename(columns={\"index\": \"feature\"})\n", "(\n", " themed_gt(GT(stats), n_rows=len(stats))\n", " .tab_header(\n", " title=gt_md(\"**Hotel bookings: summary statistics**\"),\n", " subtitle=\"117k bookings from two Portuguese hotels (2015-2017)\",\n", " )\n", " .tab_source_note(\"Antonio et al., 2019 | Data in Brief\")\n", ")" ] }, { "cell_type": "code", "execution_count": null, "id": "12", "metadata": {}, "outputs": [], "source": [ "TARGET_REG: str = \"adr\"\n", "TARGET_CLF: str = \"is_canceled\"" ] }, { "cell_type": "markdown", "id": "13", "metadata": {}, "source": [ "### What a room actually costs\n", "\n", "A table of means and quartiles hides the shape of the distribution. Chart it instead." ] }, { "cell_type": "code", "execution_count": null, "id": "14", "metadata": {}, "outputs": [], "source": [ "(\n", " ggplot(df[[\"adr\"]], aes(x=\"adr\"))\n", " + geom_bar(stat=\"bin\", fill=INFO, alpha=0.85, bins=60)\n", " + labs(\n", " title=\"Distribution of average daily rate\",\n", " subtitle=\"Regression target: room price per night, in euros\",\n", " x=\"ADR (euros)\",\n", " y=\"Bookings\",\n", " )\n", " + modern_theme(grid=True)\n", ")" ] }, { "cell_type": "markdown", "id": "15", "metadata": {}, "source": [ "Mean ADR is €103.49, median is €95.00: a real but modest right skew, a long tail of expensive suites and premium dates pulling the mean above where most bookings actually sit. Not skewed enough to demand a log transform, skewed enough that a model judged purely on raw error will care more about getting the tail right than about the ordinary €80-100 booking most guests actually make." ] }, { "cell_type": "markdown", "id": "16", "metadata": {}, "source": [ "### Which bookings cancel\n", "\n", "The hook opened on a lead-time gap. Chart cancellation by hotel and you get the other half of the picture: whether both hotels share the same risk, or whether one runs hotter than the other." ] }, { "cell_type": "code", "execution_count": null, "id": "17", "metadata": {}, "outputs": [], "source": [ "cancel_df: pd.DataFrame = (\n", " df.groupby(\"hotel\", as_index=False)[\"is_canceled\"]\n", " .agg(total=\"count\", canceled=\"sum\")\n", " .assign(rate=lambda d: d[\"canceled\"] / d[\"total\"])\n", ")\n", "(\n", " ggplot(cancel_df, aes(x=\"hotel\", y=\"rate\", fill=\"hotel\"))\n", " + geom_bar(stat=\"identity\", alpha=0.85)\n", " + scale_fill_manual(values=[INFO, WARNING])\n", " + labs(\n", " title=\"Cancellation rate by hotel type\",\n", " subtitle=\"37% overall: City Hotel cancels more than Resort\",\n", " x=\"Hotel type\",\n", " y=\"Cancellation rate\",\n", " )\n", " + modern_theme(legend_pos=\"none\")\n", ")" ] }, { "cell_type": "markdown", "id": "18", "metadata": {}, "source": [ "City Hotel bookings cancel 42% of the time, Resort bookings 28%. A City Hotel guest is booking a work trip or a short city break, easier to change plans on than a holiday already built around a resort stay. That's a hypothesis, not a fact the chart proves, but it's the kind of question worth carrying into feature selection: hotel type alone is doing real work before a single model gets involved." ] }, { "cell_type": "markdown", "id": "19", "metadata": {}, "source": [ "
df.groupby(\"is_canceled\")[\"lead_time\"].median()\n", "
fit and transform. It learns something about the data's shape, a mean, a scale, a set of categories, and never touches a target. A Predictor has fit(X, y) and predict(X). It learns a mapping to a known answer. StandardScaler, OneHotEncoder, and every other preprocessing step you meet in this book are Transformers. LinearRegression, LogisticRegression, and every model that follows are Predictors. Chapter 32's Pipeline exists because a real project chains several of the first into exactly one of the second, and having a name for each half is what makes that chain readable.\n",
"\n",
"# Wrong: the scaler sees every row, including the ones about to become test data\n",
"X_scaled = StandardScaler().fit_transform(X)\n",
"X_train_s, X_test_s = X_scaled[:n_train], X_scaled[n_train:]\n",
"\n",
"# Correct: split first, fit on training rows only\n",
"X_tr, X_te, y_tr, y_te = train_test_split(X, y)\n",
"sc = StandardScaler().fit(X_tr)\n",
"X_train_s = sc.transform(X_tr)\n",
"X_test_s = sc.transform(X_te)\n",
"\n",
"\n",
"The wrong version still runs. It still produces a number. That number is just measuring something other than what it claims to.\n",
"RobustScaler on X_tr (it centres on the median and scales by interquartile range instead of mean and standard deviation, so outliers pull it around less). Compare the scaled ranges against StandardScaler's above.\n",
"from sklearn.preprocessing import RobustScaler\n",
"# your code here\n",
"Which feature moves the most compared to StandardScaler, and does that match which one had the widest raw range back in the first cell of this section?\n",
"Ridge adds an L2 penalty that shrinks coefficients toward zero, which can reduce overfitting on noisier data.\n",
"from sklearn.linear_model import Ridge\n",
"ridge = Ridge(alpha=1.0)\n",
"ridge.fit(X_train_s, y_tr)\n",
"# compare evaluate_regressor(ridge, ...) against lin_reg\n",
"Given R2 was already only 0.10 with plain LinearRegression, do you expect Ridge to move the number much? Check whether it does.\n",
"clf_pipe using scoring=\"f1\" and StratifiedKFold in place of KFold. Does the cancellation classifier show the same flat, converged shape, or is there still a gap between train and validation that more data might close?\n",
"