{ "cells": [ { "cell_type": "markdown", "id": "0", "metadata": {}, "source": [ "---\n", "title: \"Chapter 32: Sklearn pipelines and hyperparameter search\"\n", "---" ] }, { "cell_type": "markdown", "id": "1", "metadata": {}, "source": [ "[](https://colab.research.google.com/github/sambaiga/ds-mlops-path/blob/main/tutorials/04-ml-intro/32-sklearn-pipeline.ipynb) [](https://raw.githubusercontent.com/sambaiga/ds-mlops-path/main/tutorials/04-ml-intro/32-sklearn-pipeline.ipynb)" ] }, { "cell_type": "markdown", "id": "2", "metadata": {}, "source": [ "Six numeric columns and an F1 of 0.55, that was Chapter 31's stopping point. Add six categorical columns, hotel, meal plan, market segment, distribution channel, customer type, deposit type, change nothing else, and that same LogisticRegression jumps to 0.70. Almost the same gap Chapter 31 opened with between a useless model and a working one, closed by six columns Chapter 31 never touched, exactly the room type and season signal it named as missing.\n", "\n", "That jump is also where the risk gets worse. Chapter 31 counted six separate fit calls, scaler and model, twice over, as six places a leak could slip in. A categorical column needs its own encoder, fit its own way, on training data only, same as a scaler. Fourteen columns across two different treatments is no longer six fit calls to keep straight, it is closer to a dozen, and remembering the right order by hand stops being realistic." ] }, { "cell_type": "markdown", "id": "3", "metadata": {}, "source": [ "A `Pipeline` is scikit-learn's answer: every step, however many, wrapped into one object with one `fit` and one `predict`. Fit it and it fits every stage in sequence using only training data. Call `predict` and it applies every transformation in the same order, leakage no longer possible because there's no manual step left to get wrong. This chapter builds one for the full fourteen-column feature set, tunes it three different ways, and closes the ML landscape part: [Chapter 30](30-ml-workflow.qmd)'s workflow, [Chapter 31](31-sklearn-core.ipynb)'s two interfaces, combined into a single deployable 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 | Wrap preprocessing and a model into a single Pipeline object, and explain what that buys over separate fit calls | Sec. 2 |\n", "| 2 | Route numeric and categorical columns to different treatments with ColumnTransformer | Sec. 3 |\n", "| 3 | Keep a Pipeline leakage-proof inside cross_val_score, GridSearchCV, and RandomizedSearchCV | Sec. 4 |\n", "| 4 | Compare model families on default hyperparameters before spending a tuning budget on any one of them | Sec. 5 |\n", "| 5 | Search hyperparameters with GridSearchCV, RandomizedSearchCV, and Optuna's TPE sampler | Sec. 6, 7 |\n", "| 6 | Read feature importances from a fitted pipeline, and recognise when a surprising one needs investigating rather than explaining away | Sec. 8 |\n", ":::" ] }, { "cell_type": "markdown", "id": "5", "metadata": {}, "source": [ "## 1. Growing the feature set\n", "\n", "Same loader as [Chapter 31](31-sklearn-core.ipynb), same dataset. What changes is how much of it gets used." ] }, { "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_line,\n", " geom_point,\n", " ggplot,\n", " labs,\n", " scale_fill_manual,\n", ")\n", "from loguru import logger\n", "import numpy as np\n", "import optuna\n", "import pandas as pd\n", "from scipy.stats import loguniform\n", "from sklearn.compose import ColumnTransformer\n", "from sklearn.dummy import DummyClassifier\n", "from sklearn.ensemble import HistGradientBoostingClassifier, RandomForestClassifier\n", "from sklearn.impute import SimpleImputer\n", "from sklearn.linear_model import LogisticRegression\n", "from sklearn.metrics import classification_report, f1_score\n", "from sklearn.model_selection import (\n", " GridSearchCV,\n", " RandomizedSearchCV,\n", " StratifiedKFold,\n", " cross_val_score,\n", " train_test_split,\n", ")\n", "from sklearn.pipeline import Pipeline, make_pipeline\n", "from sklearn.preprocessing import OneHotEncoder, 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, SUCCESS\n", "\n", "optuna.logging.set_verbosity(optuna.logging.WARNING)\n", "LetsPlot.setup_html()" ] }, { "cell_type": "code", "execution_count": null, "id": "7", "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", "RANDOM_STATE: int = 42\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\n", "\n", "\n", "df: pd.DataFrame = load_hotel_data()\n", "logger.info(f\"Shape: {df.shape[0]:,} rows x {df.shape[1]} columns\")" ] }, { "cell_type": "markdown", "id": "8", "metadata": {}, "source": [ "Eight numeric columns this time, [Chapter 31](31-sklearn-core.ipynb)'s original six plus two more, and six categorical ones that a plain `StandardScaler` has no way to handle: a hotel name or a deposit type isn't a number, and forcing one into a numeric column would invent an ordering between categories that was never there." ] }, { "cell_type": "code", "execution_count": null, "id": "9", "metadata": {}, "outputs": [], "source": [ "NUMERICAL_FEATURES: list[str] = [\n", " \"lead_time\",\n", " \"stays_in_weekend_nights\",\n", " \"stays_in_week_nights\",\n", " \"adults\",\n", " \"previous_cancellations\",\n", " \"booking_changes\",\n", " \"total_of_special_requests\",\n", " \"days_in_waiting_list\",\n", "]\n", "CATEGORICAL_FEATURES: list[str] = [\n", " \"hotel\",\n", " \"meal\",\n", " \"market_segment\",\n", " \"distribution_channel\",\n", " \"customer_type\",\n", " \"deposit_type\",\n", "]\n", "ALL_FEATURES: list[str] = NUMERICAL_FEATURES + CATEGORICAL_FEATURES\n", "TARGET_CLF: str = \"is_canceled\"" ] }, { "cell_type": "markdown", "id": "10", "metadata": {}, "source": [ "Cardinality, how many distinct values each categorical column has, matters before any encoding happens: it's exactly how many new binary columns `OneHotEncoder` is about to create." ] }, { "cell_type": "code", "execution_count": null, "id": "11", "metadata": {}, "outputs": [], "source": [ "cat_summary: list[dict[str, Any]] = [\n", " {\"feature\": c, \"n_unique\": df[c].nunique(), \"values\": str(sorted(df[c].unique().tolist())[:6])}\n", " for c in CATEGORICAL_FEATURES\n", "]\n", "themed_gt(GT(pd.DataFrame(cat_summary)), n_rows=len(CATEGORICAL_FEATURES)).tab_header(\n", " title=gt_md(\"**Categorical features**\"),\n", " subtitle=\"Cardinality determines how many one-hot columns each one becomes\",\n", ")" ] }, { "cell_type": "markdown", "id": "12", "metadata": {}, "source": [ "Twenty-eight possible one-hot columns from six features, `hotel` contributing 2 and `market_segment` contributing 8. Split before any of that encoding happens, same rule as [Chapter 31](31-sklearn-core.ipynb), now protecting an encoder's learned categories instead of just a scaler's mean:" ] }, { "cell_type": "code", "execution_count": null, "id": "13", "metadata": {}, "outputs": [], "source": [ "X: pd.DataFrame = df[ALL_FEATURES].copy()\n", "y: np.ndarray = df[TARGET_CLF].to_numpy(dtype=int)\n", "\n", "X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=RANDOM_STATE, stratify=y)\n", "logger.info(f\"Train: {X_train.shape} Test: {X_test.shape}\")\n", "logger.info(f\"Train cancel rate: {y_train.mean():.2%} Test cancel rate: {y_test.mean():.2%}\")" ] }, { "cell_type": "markdown", "id": "14", "metadata": {}, "source": [ "## 2. What a Pipeline actually buys you\n", "\n", "Rebuild [Chapter 31](31-sklearn-core.ipynb)'s six-numeric-feature model first, this time as a `Pipeline` instead of two separate objects called in sequence. Nothing about the model changes, only how the scaler and the classifier are held together." ] }, { "cell_type": "code", "execution_count": null, "id": "15", "metadata": {}, "outputs": [], "source": [ "NUM6: list[str] = NUMERICAL_FEATURES[:6]\n", "pipe_simple: Pipeline = Pipeline(\n", " [\n", " (\"scaler\", StandardScaler()),\n", " (\"model\", LogisticRegression(class_weight=\"balanced\", max_iter=1000, random_state=RANDOM_STATE)),\n", " ]\n", ")\n", "X_train_num: pd.DataFrame = X_train[NUM6]\n", "X_test_num: pd.DataFrame = X_test[NUM6]\n", "pipe_simple.fit(X_train_num, y_train)\n", "logger.success(\"Simple pipeline fitted\")" ] }, { "cell_type": "markdown", "id": "16", "metadata": {}, "source": [ "`named_steps` gives dictionary access into a fitted Pipeline, useful for checking that a stage learned what you expect without breaking the single-object abstraction:" ] }, { "cell_type": "code", "execution_count": null, "id": "17", "metadata": {}, "outputs": [], "source": [ "fitted_scaler: StandardScaler = pipe_simple.named_steps[\"scaler\"]\n", "logger.info(f\"Scaler means (first 4): {fitted_scaler.mean_[:4].round(2)}\")\n", "logger.info(f\"Pipeline steps: {list(pipe_simple.named_steps.keys())}\")" ] }, { "cell_type": "markdown", "id": "18", "metadata": {}, "source": [ "Check whether it can actually predict:" ] }, { "cell_type": "code", "execution_count": null, "id": "19", "metadata": {}, "outputs": [], "source": [ "y_pred_simple: np.ndarray = pipe_simple.predict(X_test_num)\n", "f1_simple: float = f1_score(y_test, y_pred_simple)\n", "logger.info(f\"Simple pipeline F1 (six numeric features): {f1_simple:.3f}\")" ] }, { "cell_type": "markdown", "id": "20", "metadata": {}, "source": [ "0.549, exactly [Chapter 31](31-sklearn-core.ipynb)'s number. Same model, same features, same split, wrapped differently. That's the guarantee worth trusting before anything gets more complicated: a `Pipeline` doesn't change what a model learns, it changes how safely you can call `fit` on it." ] }, { "cell_type": "markdown", "id": "21", "metadata": {}, "source": [ "
Pipeline exposes exactly the shape Chapter 31 named: fit(X, y) and predict(X), same as any single model. You can hand it to cross_val_score, GridSearchCV, or RandomizedSearchCV exactly as you would a bare classifier, and every one of those tools will re-fit every internal stage correctly on each fold's training data, never on its validation data. The rest of this chapter leans on that guarantee constantly, without restating it.\n",
"StandardScaler with RobustScaler inside pipe_simple and refit. Does it change F1 on this particular feature set, and does that match what you'd expect given how many outliers Chapter 31's feature ranges showed?\n",
"from sklearn.preprocessing import RobustScaler\n",
"# your code here\n",
"arrival_date_month to CATEGORICAL_FEATURES, rebuild the preprocessor and pipe_full, and refit. Does the booking's arrival month move F1 further, and does the size of that move match how much you'd expect season to matter for a hotel?\n",
"\n",
"# Leaky: the preprocessor is fitted once, on every row, before any fold is held out\n",
"X_scaled = preprocessor.fit_transform(X_train)\n",
"cross_val_score(model, X_scaled, y_train, cv=StratifiedKFold(5))\n",
"\n",
"# Correct: the Pipeline is what gets cross-validated, not its output\n",
"pipe = Pipeline([(\"preprocessor\", preprocessor), (\"model\", model)])\n",
"cross_val_score(pipe, X_train, y_train, cv=StratifiedKFold(5))\n",
"\n",
"\n",
"The leaky version's encoder has already learned every category in the full training set, including the ones sitting in whatever fold is about to be held out. The score that comes back is optimistic in a way that won't survive contact with real unseen data, and nothing about the code will tell you that happened.\n",
"param_dist for RandomForestClassifier (model__n_estimators, model__max_depth, model__min_samples_split) and run RandomizedSearchCV with 10 trials. Given Section 5's comparison, do you expect tuning to close much of the gap to LogisticRegression, or does RandomForest's advantage come from the model family itself rather than its specific settings?\n",
"max_iter to optuna_objective, searching trial.suggest_categorical(\"max_iter\", [200, 500, 1000, 2000]). Does letting Optuna pick the iteration budget change the optimal C it finds, or are the two parameters independent of each other here?\n",
"