{ "cells": [ { "cell_type": "markdown", "metadata": { "id": "8qPBlJDHwucz" }, "source": [ "Empaquetado de modelos con MLflow\n", "=================================" ] }, { "cell_type": "markdown", "metadata": { "id": "PM02vWO4wuc5" }, "source": [ "## ¿Que es MLflow?\n", "\n", "MLflow es un framework de aprendizaje automático que permite manejar multiples aspectos del ciclo de vida de desarrollo de modelos. Puntualmente provee capacidades para:\n", "\n", "1. Realizar tracking de experimentos, conocido como **MLflow tracking**.\n", "1. Administrar modelos, conocido como **MLflow Model Registry**.\n", "1. Desplegar modelos, conocido como **MLflow Models**\n", "\n", "Anteriormente en este curso utilizamos *Comet* para realizar las acividades 1) y 2). Ambos estandares permiten realizar estas operaciones de una forma similar. Sin embargo, MLflow, ademas de ser un estandard de código abierto, permite desplegar los modelos y ejecutarlos en producción.\n", "\n", "En este ejemplo, veremos como utilizar MLflow para estas operaciones:" ] }, { "cell_type": "markdown", "metadata": { "id": "PjoSE6qawuc6" }, "source": [ "## Entrenando y optimizando parámetros para el problema censo de la UCI" ] }, { "cell_type": "markdown", "metadata": { "id": "CWmaFc37wuc8" }, "source": [ "### Instalación" ] }, { "cell_type": "code", "source": [ "%pip install mlflow scikit-learn pandas numpy -q" ], "metadata": { "id": "cdT5Qd_WITOf" }, "execution_count": null, "outputs": [] }, { "cell_type": "markdown", "metadata": { "id": "CyWscZCQwudE" }, "source": [ "### Sobre el conjunto de datos del censo UCI\n", "\n", "El conjunto de datos del censo de la UCI es un conjunto de datos en el que cada registro representa a una persona. Cada registro contiene 14 columnas que describen a una una sola persona, de la base de datos del censo de Estados Unidos de 1994. Esto incluye información como la edad, el estado civil y el nivel educativo. La tarea es determinar si una persona tiene un ingreso alto (definido como ganar más de $50 mil al año). Esta tarea, dado el tipo de datos que utiliza, se usa a menudo en el estudio de equidad, en parte debido a los atributos comprensibles del conjunto de datos, incluidos algunos que contienen tipos sensibles como la edad y el género, y en parte también porque comprende una tarea claramente del mundo real." ] }, { "cell_type": "markdown", "metadata": { "id": "OnHZuGLSwudG" }, "source": [ "Descargamos el conjunto de datos" ] }, { "cell_type": "code", "execution_count": 2, "metadata": { "id": "PcnArrfQwudH" }, "outputs": [], "source": [ "!wget https://santiagxf.blob.core.windows.net/public/datasets/uci_census.zip \\\n", " --quiet --no-clobber\n", "!mkdir -p datasets/uci_census\n", "!unzip -qq uci_census.zip -d datasets/uci_census" ] }, { "cell_type": "markdown", "metadata": { "id": "0ZO-pj3WwudI" }, "source": [ "Lo importamos" ] }, { "cell_type": "code", "execution_count": 3, "metadata": { "id": "sn4kODTMwudI" }, "outputs": [], "source": [ "import pandas as pd\n", "import numpy as np\n", "\n", "train = pd.read_csv('datasets/uci_census/data/adult-train.csv')\n", "test = pd.read_csv('datasets/uci_census/data/adult-test.csv')" ] }, { "cell_type": "markdown", "metadata": { "id": "MMG5UhjewudK" }, "source": [ "### Creando un modelo utilizando pipelines" ] }, { "cell_type": "markdown", "metadata": { "id": "gdNPtv-KwudK" }, "source": [ "Preparando nuestros conjuntos de datos" ] }, { "cell_type": "code", "execution_count": 4, "metadata": { "id": "EhLsyUphwudL" }, "outputs": [], "source": [ "X_train = train.drop(['income'], axis=1)\n", "y_train = train['income'].to_numpy()\n", "X_test = test.drop(['income'], axis=1)\n", "y_test = test['income'].to_numpy()" ] }, { "cell_type": "code", "execution_count": 5, "metadata": { "id": "oZiFns7SwudL" }, "outputs": [], "source": [ "classes = train['income'].unique().tolist()\n", "features = X_train.columns.values.tolist()\n", "categorical_features = X_train.dtypes[X_train.dtypes == 'object'].index.tolist()" ] }, { "cell_type": "markdown", "metadata": { "id": "6HzsWIV1wudM" }, "source": [ "Realizaremos un pequeño preprocesamiento antes de entrenar el modelo:\n", "\n", "- Imputaremos los valores faltantes de las caracteristicas numéricas con la media\n", "- Imputaremos los valores faltantes de las caracteristicas categóricas con el valor `?`\n", "- Escalaremos los valores numericos utilizando un `StandardScaler`\n", "- Codificaremos las variables categóricas utilizando `OneHotEncoder`" ] }, { "cell_type": "code", "execution_count": 6, "metadata": { "id": "374RsZu0wudM" }, "outputs": [], "source": [ "from typing import Tuple, List\n", "\n", "import sklearn\n", "from sklearn.pipeline import Pipeline\n", "from sklearn.impute import SimpleImputer\n", "from sklearn.preprocessing import StandardScaler, OneHotEncoder\n", "from sklearn.compose import ColumnTransformer\n", "\n", "\n", "pipe_cfg = {\n", " 'num_cols': X_train.dtypes[X_train.dtypes == 'int64'].index.values.tolist(),\n", " 'cat_cols': X_train.dtypes[X_train.dtypes == 'object'].index.values.tolist(),\n", "}\n", "\n", "num_pipe = Pipeline([\n", " ('num_imputer', SimpleImputer(strategy='median')),\n", " ('num_scaler', StandardScaler())\n", "])\n", "\n", "cat_pipe = Pipeline([\n", " ('cat_imputer', SimpleImputer(strategy='constant', fill_value='?')),\n", " ('cat_encoder', OneHotEncoder(handle_unknown='ignore', sparse_output=False))\n", "])\n", "\n", "transformations = ColumnTransformer([\n", " ('num_pipe', num_pipe, pipe_cfg['num_cols']),\n", " ('cat_pipe', cat_pipe, pipe_cfg['cat_cols'])\n", "])" ] }, { "cell_type": "markdown", "metadata": { "id": "81atQ7WUwudN" }, "source": [ "Nuestro modelo estará basado en un `GradientBoostingClassifier`:" ] }, { "cell_type": "code", "source": [ "parameters = {\n", " 'subsample': 0.7,\n", " 'learning_rate': 0.07,\n", " 'max_depth': 7,\n", " 'n_estimators': 200,\n", "}" ], "metadata": { "id": "hwviLY5UZfFA" }, "execution_count": 7, "outputs": [] }, { "cell_type": "code", "source": [ "from sklearn.ensemble import GradientBoostingClassifier\n", "\n", "base_model = GradientBoostingClassifier(**parameters)" ], "metadata": { "id": "-Yp_8U4vfg-g" }, "execution_count": 8, "outputs": [] }, { "cell_type": "markdown", "source": [ "> **Tip:** Si no conocía el operador `**` en Python, el mismo tiene 2 significados: El primero, la operación exponencial (como en `2**4`). El segundo, cuando el operador está en frente de un diccionario, \"desempaca\" los valores de un diccionario y los pasa como argumentos a una función. En este emplo, los diferentes valores del dicionario están siendo utilizados como argumentos para contruir el `XGBClassifier`." ], "metadata": { "id": "kpk47f4JAEHZ" } }, { "cell_type": "markdown", "source": [ "En esta ocacion, en lugar de ejecutar el preprocesamiento sobre el conjunto de datos y luego enviarlo al modelo, construiremos un nuevo pipeline que contendrá los 2 pasos. La librería Scikit-learn le permite realizar estas combinaciones:" ], "metadata": { "id": "wruW2ybyMwmf" } }, { "cell_type": "code", "source": [ "model_pipeline = Pipeline([\n", " ('preprocessing', transformations),\n", " ('classifier', base_model),\n", "])" ], "metadata": { "id": "i8jU0i2B-_Vo" }, "execution_count": 9, "outputs": [] }, { "cell_type": "markdown", "source": [ "Entrenemos el pipeline completo. Note que en esta acción, todos los pasos del pipeline serán ajustados (fit). Esto quiere decir que cualquier valor que se requiera aprender para ejecutar las transformaciones sucederá en este paso. También el modelo se ajustará y estimará sus parámetros:" ], "metadata": { "id": "v8Y83om7i93A" } }, { "cell_type": "code", "source": [ "model = model_pipeline.fit(X_train, y_train)" ], "metadata": { "id": "eDQfS6CMZy9b" }, "execution_count": 10, "outputs": [] }, { "cell_type": "markdown", "metadata": { "id": "gToVaGBLa7np" }, "source": [ "Note como el pipeline completo tiene el método predict para retornar predicciones. Sin embargo, este metodo ahora toma como entrada predictores sin procesar directamente. El mismo pipeline se encarga de preprocesarlos:" ] }, { "cell_type": "code", "execution_count": 11, "metadata": { "id": "etp79VUBa7nr" }, "outputs": [], "source": [ "predictions = model.predict(X_test)" ] }, { "cell_type": "markdown", "source": [ "Podemos revisar la performance del modelo:" ], "metadata": { "id": "6eavFPl9a7nr" } }, { "cell_type": "code", "source": [ "from sklearn.metrics import classification_report\n", "\n", "print(classification_report(y_test, predictions))" ], "metadata": { "colab": { "base_uri": "https://localhost:8080/" }, "outputId": "10937eda-d265-47c0-a069-27bf40e1211b", "id": "c-EUxBzfa7ns" }, "execution_count": 12, "outputs": [ { "output_type": "stream", "name": "stdout", "text": [ " precision recall f1-score support\n", "\n", " <=50K 0.90 0.94 0.92 12435\n", " >50K 0.77 0.66 0.71 3846\n", "\n", " accuracy 0.87 16281\n", " macro avg 0.83 0.80 0.81 16281\n", "weighted avg 0.87 0.87 0.87 16281\n", "\n" ] } ] }, { "cell_type": "markdown", "source": [ "### Empaquetando el modelo con MLflow\n", "\n", "Empaquetaremos el modelo que construimos con MLflow. MLflow dispone de un estandar para realizar este empaquetado, MLmodel, el cual dispone de un conjunto de metadata que hace más sencillo su despliegue luego:" ], "metadata": { "id": "_x3qmlR-NaLO" } }, { "cell_type": "code", "source": [ "import mlflow\n", "from mlflow.models.signature import infer_signature" ], "metadata": { "id": "dlqlAsh6IQ1Z" }, "execution_count": 13, "outputs": [] }, { "cell_type": "code", "source": [ "mlflow.sklearn.save_model(\n", " sk_model=model,\n", " path=\"model\",\n", " signature=infer_signature(X_test, predictions)\n", ")" ], "metadata": { "id": "KQaYyJYZIXfQ", "outputId": "b81dd3b0-9feb-4c5f-b7e7-cfb8bed32d96", "colab": { "base_uri": "https://localhost:8080/" } }, "execution_count": 14, "outputs": [ { "output_type": "stream", "name": "stderr", "text": [ "/usr/local/lib/python3.11/dist-packages/mlflow/types/utils.py:452: UserWarning: Hint: Inferred schema contains integer column(s). Integer columns in Python cannot represent missing values. If your input data contains missing values at inference time, it will be encoded as floats and will cause a schema enforcement error. The best way to avoid this problem is to infer the model schema based on a realistic data sample (training dataset) that includes missing values. Alternatively, you can declare integer columns as doubles (float64) whenever these columns may have missing values. See `Handling Integers With Missing Values `_ for more details.\n", " warnings.warn(\n" ] } ] }, { "cell_type": "markdown", "source": [ "> El método `infer_signature` le permite a MLflow identificar cuales son los predictores de entrada que el modelo necesita y cuales son las predicciones que genera:" ], "metadata": { "id": "Q4XpSfCvNmLA" } }, { "cell_type": "code", "source": [ "!ls model" ], "metadata": { "id": "ThfDZbxOIrQ4", "outputId": "477d6e7e-87f4-482a-a9ec-ddbc955b2a62", "colab": { "base_uri": "https://localhost:8080/" } }, "execution_count": 15, "outputs": [ { "output_type": "stream", "name": "stdout", "text": [ "conda.yaml MLmodel model.pkl\tpython_env.yaml requirements.txt\n" ] } ] }, { "cell_type": "markdown", "source": [ "Podemos inspeccionar como luce la metadata de este paquete:" ], "metadata": { "id": "Qiw6vIABNybh" } }, { "cell_type": "code", "source": [ "!cat model/MLmodel" ], "metadata": { "id": "5l_5Ixz3L3Rs", "outputId": "dc8957ec-0639-451b-a731-95b0f0542427", "colab": { "base_uri": "https://localhost:8080/" } }, "execution_count": 16, "outputs": [ { "output_type": "stream", "name": "stdout", "text": [ "flavors:\n", " python_function:\n", " env:\n", " conda: conda.yaml\n", " virtualenv: python_env.yaml\n", " loader_module: mlflow.sklearn\n", " model_path: model.pkl\n", " predict_fn: predict\n", " python_version: 3.11.13\n", " sklearn:\n", " code: null\n", " pickled_model: model.pkl\n", " serialization_format: cloudpickle\n", " sklearn_version: 1.6.1\n", "is_signature_from_type_hint: false\n", "mlflow_version: 3.1.1\n", "model_id: null\n", "model_size_bytes: 2635663\n", "model_uuid: bb11865009874804a62217faa6470f0d\n", "prompts: null\n", "signature:\n", " inputs: '[{\"type\": \"long\", \"name\": \"age\", \"required\": true}, {\"type\": \"string\",\n", " \"name\": \"workclass\", \"required\": true}, {\"type\": \"long\", \"name\": \"fnlwgt\", \"required\":\n", " true}, {\"type\": \"string\", \"name\": \"education\", \"required\": true}, {\"type\": \"long\",\n", " \"name\": \"education-num\", \"required\": true}, {\"type\": \"string\", \"name\": \"marital-status\",\n", " \"required\": true}, {\"type\": \"string\", \"name\": \"occupation\", \"required\": true},\n", " {\"type\": \"string\", \"name\": \"relationship\", \"required\": true}, {\"type\": \"string\",\n", " \"name\": \"race\", \"required\": true}, {\"type\": \"string\", \"name\": \"gender\", \"required\":\n", " true}, {\"type\": \"long\", \"name\": \"capital-gain\", \"required\": true}, {\"type\": \"long\",\n", " \"name\": \"capital-loss\", \"required\": true}, {\"type\": \"long\", \"name\": \"hours-per-week\",\n", " \"required\": true}, {\"type\": \"string\", \"name\": \"native-country\", \"required\": true}]'\n", " outputs: '[{\"type\": \"tensor\", \"tensor-spec\": {\"dtype\": \"object\", \"shape\": [-1]}}]'\n", " params: null\n", "type_hint_from_example: false\n", "utc_time_created: '2025-06-29 18:57:02.161698'\n" ] } ] }, { "cell_type": "markdown", "source": [ "### Utilizando un modelo previamente empaquetado en producción\n", "\n", "Una vez que su modelo ha sido empaquetado, puede ser cargado para utilizarce de forma productiva. Recuerde que previamente guardamos el modelo en un directorio llamado `model`. Utilizaremos este parametro en varias partes siguidamente:" ], "metadata": { "id": "zmZaJp1IDM6A" } }, { "cell_type": "code", "source": [ "import mlflow\n", "\n", "loaded_model = mlflow.pyfunc.load_model(model_uri=\"model\")" ], "metadata": { "id": "bXfHVzNEDWBj" }, "execution_count": 17, "outputs": [] }, { "cell_type": "markdown", "source": [ "> `pyfunc` es un tipo de modelo en MLflow generico que puede cargar cualquier tipo de modelo. Esto hace que en producción no deba preocuparse por saber cual fué el framework que los desarroladores han utilizado." ], "metadata": { "id": "Vzb2bpv_Dhhr" } }, { "cell_type": "markdown", "source": [ "`loaded_model` es un objeto en Python que dispone de la función `predict`, la cual puede utilizar para ejecutar el modelo." ], "metadata": { "id": "6BX-K5mvEzpO" } }, { "cell_type": "markdown", "source": [ "Puede también obtener información sobre el ambiente que se necesita para este modelo:" ], "metadata": { "id": "oE42NtxDD_yu" } }, { "cell_type": "code", "source": [ "mlflow.models.infer_pip_requirements(\"model\", flavor=\"python_function\")" ], "metadata": { "id": "GLh4ijAYEJKy", "outputId": "1c5a98ac-0287-4650-b662-021f4c056bed", "colab": { "base_uri": "https://localhost:8080/" } }, "execution_count": 24, "outputs": [ { "output_type": "execute_result", "data": { "text/plain": [ "['numpy==2.0.2',\n", " 'pandas==2.2.2',\n", " 'pathlib==1.0.1',\n", " 'psutil==5.9.5',\n", " 'scikit-learn==1.6.1',\n", " 'scipy==1.15.3']" ] }, "metadata": {}, "execution_count": 24 } ] }, { "cell_type": "markdown", "source": [ "Incluso, puede obtener información sobre que parametros necesita el modelo para ejecutarse:" ], "metadata": { "id": "IvDfXCD7FAbo" } }, { "cell_type": "code", "source": [ "from pprint import pprint\n", "\n", "pprint(mlflow.models.get_model_info(\"model\").signature)" ], "metadata": { "id": "pqx-brzeFEjk", "outputId": "f2f91658-5d83-4dce-c923-a65bab978baf", "colab": { "base_uri": "https://localhost:8080/" } }, "execution_count": 27, "outputs": [ { "output_type": "stream", "name": "stdout", "text": [ "inputs: \n", " ['age': long (required), 'workclass': string (required), 'fnlwgt': long (required), 'education': string (required), 'education-num': long (required), 'marital-status': string (required), 'occupation': string (required), 'relationship': string (required), 'race': string (required), 'gender': string (required), 'capital-gain': long (required), 'capital-loss': long (required), 'hours-per-week': long (required), 'native-country': string (required)]\n", "outputs: \n", " [Tensor('object', (-1,))]\n", "params: \n", " None\n", "\n" ] } ] }, { "cell_type": "markdown", "source": [ "Ejecutemos el modelo sobre un conjunto de datos cualquiera." ], "metadata": { "id": "hxSSk4f6D5cT" } }, { "cell_type": "code", "source": [ "predictions = loaded_model.predict(X_test)" ], "metadata": { "id": "j4x16_KZDfy9" }, "execution_count": 18, "outputs": [] }, { "cell_type": "code", "source": [ "predictions[-1]" ], "metadata": { "id": "xf6IX9s6Dx7d", "outputId": "3eb21e37-2f10-4442-a2af-a9c5e7b5b710", "colab": { "base_uri": "https://localhost:8080/", "height": 35 } }, "execution_count": 20, "outputs": [ { "output_type": "execute_result", "data": { "text/plain": [ "'>50K'" ], "application/vnd.google.colaboratory.intrinsic+json": { "type": "string" } }, "metadata": {}, "execution_count": 20 } ] } ], "metadata": { "language_info": { "name": "python" }, "vscode": { "interpreter": { "hash": "c0c26a04c01997af4d3a54c44ba2029caf4208eaf3de13f3aa81bddca06af044" } }, "colab": { "provenance": [], "toc_visible": true }, "kernelspec": { "name": "python3", "display_name": "Python 3" } }, "nbformat": 4, "nbformat_minor": 0 }