{ "cells": [ { "attachments": {}, "cell_type": "markdown", "id": "66a105c6", "metadata": {}, "source": [ "# Adding a custom model to AutoGluon\n", "\n", "[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/autogluon/autogluon/blob/stable/docs/tutorials/tabular/advanced/tabular-custom-model.ipynb)\n", "[![Open In SageMaker Studio Lab](https://studiolab.sagemaker.aws/studiolab.svg)](https://studiolab.sagemaker.aws/import/github/autogluon/autogluon/blob/stable/docs/tutorials/tabular/advanced/tabular-custom-model.ipynb)\n", "\n", "\n", "\n", "**Tip**: If you are new to AutoGluon, review [Predicting Columns in a Table - Quick Start](../tabular-quick-start.ipynb) to learn the basics of the AutoGluon API.\n", "\n", "This tutorial describes how to add a custom model to AutoGluon that can be trained, hyperparameter-tuned, and ensembled alongside the default models ([default model documentation](../../../api/autogluon.tabular.models.rst)).\n", "\n", "In this example, we create a custom Random Forest model for use in AutoGluon. All models in AutoGluon inherit from the AbstractModel class ([AbstractModel source code](https://auto.gluon.ai/stable/_modules/autogluon/core/models/abstract/abstract_model.html)), and must follow its API to work alongside other models.\n", "\n", "Note that while this tutorial provides a basic model implementation, this does not cover many aspects that are used in most implemented models.\n", "\n", "To best understand how to implement more advanced functionality, refer to the [source code](../../../api/autogluon.tabular.models.rst) of the following models:\n", "\n", "| Functionality | Reference Implementation |\n", "| ------------- | ------------------------ |\n", "| Respecting time limit / early stopping logic | [LGBModel](https://auto.gluon.ai/stable/_modules/autogluon/tabular/models/lgb/lgb_model.html) and [RFModel](https://auto.gluon.ai/stable/_modules/autogluon/tabular/models/rf/rf_model.html)\n", "| Respecting memory usage limit | LGBModel and RFModel\n", "| Sample weight support | LGBModel\n", "| Validation data and eval_metric usage | LGBModel\n", "| GPU training support | LGBModel\n", "| Save / load logic of non-serializable models | [NNFastAiTabularModel](https://auto.gluon.ai/stable/_modules/autogluon/tabular/models/fastainn/tabular_nn_fastai.html)\n", "| Advanced problem type support (Softclass, Quantile) | RFModel\n", "| Text feature type support | [TextPredictorModel](https://auto.gluon.ai/stable/_modules/autogluon/tabular/models/text_prediction/text_prediction_v1_model.html)\n", "| Image feature type support | [ImagePredictorModel](https://auto.gluon.ai/stable/_modules/autogluon/tabular/models/image_prediction/image_predictor.html)\n", "| Lazy import of package dependencies | LGBModel\n", "| Custom HPO logic | LGBModel\n", "\n", "## Implementing a custom model\n", "\n", "Here we define the custom model we will be working with for the rest of the tutorial.\n", "\n", "The most important methods that must be implemented are `_fit` and `_preprocess`.\n", "\n", "To compare with the official AutoGluon Random Forest implementation, see the [RFModel](https://auto.gluon.ai/stable/_modules/autogluon/tabular/models/rf/rf_model.html) source code.\n", "\n", "Follow along with the code comments to better understand how the code works." ] }, { "cell_type": "code", "execution_count": null, "id": "aa00faab-252f-44c9-b8f7-57131aa8251c", "metadata": { "tags": [ "remove-cell" ] }, "outputs": [], "source": [ "!pip install autogluon.tabular[all]\n" ] }, { "cell_type": "code", "execution_count": null, "id": "24f8e681", "metadata": {}, "outputs": [], "source": [ "import numpy as np\n", "import pandas as pd\n", "\n", "from autogluon.core.models import AbstractModel\n", "from autogluon.features.generators import LabelEncoderFeatureGenerator\n", "\n", "class CustomRandomForestModel(AbstractModel):\n", " def __init__(self, **kwargs):\n", " # Simply pass along kwargs to parent, and init our internal `_feature_generator` variable to None\n", " super().__init__(**kwargs)\n", " self._feature_generator = None\n", "\n", " # The `_preprocess` method takes the input data and transforms it to the internal representation usable by the model.\n", " # `_preprocess` is called by `preprocess` and is used during model fit and model inference.\n", " def _preprocess(self, X: pd.DataFrame, is_train=False, **kwargs) -> np.ndarray:\n", " print(f'Entering the `_preprocess` method: {len(X)} rows of data (is_train={is_train})')\n", " X = super()._preprocess(X, **kwargs)\n", "\n", " if is_train:\n", " # X will be the training data.\n", " self._feature_generator = LabelEncoderFeatureGenerator(verbosity=0)\n", " self._feature_generator.fit(X=X)\n", " if self._feature_generator.features_in:\n", " # This converts categorical features to numeric via stateful label encoding.\n", " X = X.copy()\n", " X[self._feature_generator.features_in] = self._feature_generator.transform(X=X)\n", " # Add a fillna call to handle missing values.\n", " # Some algorithms will be able to handle NaN values internally (LightGBM).\n", " # In those cases, you can simply pass the NaN values into the inner model.\n", " # Finally, convert to numpy for optimized memory usage and because sklearn RF works with raw numpy input.\n", " return X.fillna(0).to_numpy(dtype=np.float32)\n", "\n", " # The `_fit` method takes the input training data (and optionally the validation data) and trains the model.\n", " def _fit(self,\n", " X: pd.DataFrame, # training data\n", " y: pd.Series, # training labels\n", " # X_val=None, # val data (unused in RF model)\n", " # y_val=None, # val labels (unused in RF model)\n", " # time_limit=None, # time limit in seconds (ignored in tutorial)\n", " **kwargs): # kwargs includes many other potential inputs, refer to AbstractModel documentation for details\n", " print('Entering the `_fit` method')\n", "\n", " # First we import the required dependencies for the model. Note that we do not import them outside of the method.\n", " # This enables AutoGluon to be highly extensible and modular.\n", " # For an example of best practices when importing model dependencies, refer to LGBModel.\n", " from sklearn.ensemble import RandomForestClassifier, RandomForestRegressor\n", "\n", " # Valid self.problem_type values include ['binary', 'multiclass', 'regression', 'quantile', 'softclass']\n", " if self.problem_type in ['regression', 'softclass']:\n", " model_cls = RandomForestRegressor\n", " else:\n", " model_cls = RandomForestClassifier\n", "\n", " # Make sure to call preprocess on X near the start of `_fit`.\n", " # This is necessary because the data is converted via preprocess during predict, and needs to be in the same format as during fit.\n", " X = self.preprocess(X, is_train=True)\n", " # This fetches the user-specified (and default) hyperparameters for the model.\n", " params = self._get_model_params()\n", " print(f'Hyperparameters: {params}')\n", " # self.model should be set to the trained inner model, so that internally during predict we can call `self.model.predict(...)`\n", " self.model = model_cls(**params)\n", " self.model.fit(X, y)\n", " print('Exiting the `_fit` method')\n", "\n", " # The `_set_default_params` method defines the default hyperparameters of the model.\n", " # User-specified parameters will override these values on a key-by-key basis.\n", " def _set_default_params(self):\n", " default_params = {\n", " 'n_estimators': 300,\n", " 'n_jobs': -1,\n", " 'random_state': 0,\n", " }\n", " for param, val in default_params.items():\n", " self._set_default_param_value(param, val)\n", "\n", " # The `_get_default_auxiliary_params` method defines various model-agnostic parameters such as maximum memory usage and valid input column dtypes.\n", " # For most users who build custom models, they will only need to specify the valid/invalid dtypes to the model here.\n", " def _get_default_auxiliary_params(self) -> dict:\n", " default_auxiliary_params = super()._get_default_auxiliary_params()\n", " extra_auxiliary_params = dict(\n", " # the total set of raw dtypes are: ['int', 'float', 'category', 'object', 'datetime']\n", " # object feature dtypes include raw text and image paths, which should only be handled by specialized models\n", " # datetime raw dtypes are generally converted to int in upstream pre-processing,\n", " # so models generally shouldn't need to explicitly support datetime dtypes.\n", " valid_raw_types=['int', 'float', 'category'],\n", " # Other options include `valid_special_types`, `ignored_type_group_raw`, and `ignored_type_group_special`.\n", " # Refer to AbstractModel for more details on available options.\n", " )\n", " default_auxiliary_params.update(extra_auxiliary_params)\n", " return default_auxiliary_params\n" ] }, { "cell_type": "markdown", "id": "d5a792ec", "metadata": {}, "source": [ "## Loading the data\n", "\n", "Next we will load the data. For this tutorial we will use the adult income dataset because it has a mix of integer, float, and categorical features." ] }, { "cell_type": "code", "execution_count": null, "id": "1a46897c", "metadata": {}, "outputs": [], "source": [ "from autogluon.tabular import TabularDataset\n", "\n", "train_data = TabularDataset('https://autogluon.s3.amazonaws.com/datasets/Inc/train.csv') # can be local CSV file as well, returns Pandas DataFrame\n", "test_data = TabularDataset('https://autogluon.s3.amazonaws.com/datasets/Inc/test.csv') # another Pandas DataFrame\n", "label = 'class' # specifies which column do we want to predict\n", "train_data = train_data.sample(n=1000, random_state=0) # subsample for faster demo\n", "\n", "train_data.head(5)" ] }, { "cell_type": "markdown", "id": "de577e1a", "metadata": {}, "source": [ "## Training a custom model without TabularPredictor\n", "\n", "Below we will demonstrate how to train the model outside [TabularPredictor](../../../api/autogluon.tabular.TabularPredictor.rst). This is useful for debugging and minimizing the amount of code you need to understand while implementing the model.\n", "\n", "This process is similar to what happens internally when calling fit on `TabularPredictor`, but is simplified and minimal.\n", "\n", "If the data was already cleaned (all numeric), then we could call fit directly with the data, but the adult dataset is not.\n", "\n", "### Clean labels\n", "\n", "The first step to making the input data as valid input to the model is to clean the labels.\n", "\n", "Currently, they are strings, but we need to convert them to numeric values (0 and 1) for binary classification.\n", "\n", "Luckily, AutoGluon already implements logic to both detect that this is binary classification (via `infer_problem_type`), and a converter to map the labels to 0 and 1 (`LabelCleaner`):" ] }, { "cell_type": "code", "execution_count": null, "id": "2cc5dc61", "metadata": {}, "outputs": [], "source": [ "# Separate features and labels\n", "X = train_data.drop(columns=[label])\n", "y = train_data[label]\n", "X_test = test_data.drop(columns=[label])\n", "y_test = test_data[label]\n", "\n", "from autogluon.core.data import LabelCleaner\n", "from autogluon.core.utils import infer_problem_type\n", "# Construct a LabelCleaner to neatly convert labels to float/integers during model training/inference, can also use to inverse_transform back to original.\n", "problem_type = infer_problem_type(y=y) # Infer problem type (or else specify directly)\n", "label_cleaner = LabelCleaner.construct(problem_type=problem_type, y=y)\n", "y_clean = label_cleaner.transform(y)\n", "\n", "print(f'Labels cleaned: {label_cleaner.inv_map}')\n", "print(f'inferred problem type as: {problem_type}')\n", "print('Cleaned label values:')\n", "y_clean.head(5)" ] }, { "cell_type": "markdown", "id": "be512ca5", "metadata": {}, "source": [ "### Clean features\n", "\n", "Next, we need to clean the features. Currently, features like 'workclass' are object dtypes (strings), but we actually want to use them as categorical features. Most models won't accept string inputs, so we need to convert the strings to numbers.\n", "\n", "AutoGluon contains an entire module dedicated to cleaning, transforming, and generating features called [autogluon.features](../../../api/autogluon.features.rst). Here we will use the same feature generator used internally by `TabularPredictor` to convert the object dtypes to categorical and minimize memory usage." ] }, { "cell_type": "code", "execution_count": null, "id": "442d9a95", "metadata": {}, "outputs": [], "source": [ "from autogluon.common.utils.log_utils import set_logger_verbosity\n", "from autogluon.features.generators import AutoMLPipelineFeatureGenerator\n", "set_logger_verbosity(2) # Set logger so more detailed logging is shown for tutorial\n", "\n", "feature_generator = AutoMLPipelineFeatureGenerator()\n", "X_clean = feature_generator.fit_transform(X)\n", "\n", "X_clean.head(5)" ] }, { "cell_type": "markdown", "id": "b71b9457", "metadata": {}, "source": [ "[AutoMLPipelineFeatureGenerator](../../../api/autogluon.features.rst#AutoMLPipelineFeatureGenerator) does not fill missing values for numeric features nor does it rescale the values of numeric features or one-hot encode categoricals. If a model requires these operations, you'll need to add these operations into your `_preprocess` method, and may find some FeatureGenerator classes useful for this.\n", "\n", "### Fit model\n", "\n", "We are now ready to fit the model with the cleaned features and labels." ] }, { "cell_type": "code", "execution_count": null, "id": "defe0cd6", "metadata": {}, "outputs": [], "source": [ "custom_model = CustomRandomForestModel()\n", "# We could also specify hyperparameters to override defaults\n", "# custom_model = CustomRandomForestModel(hyperparameters={'max_depth': 10})\n", "custom_model.fit(X=X_clean, y=y_clean) # Fit custom model\n", "\n", "# To save to disk and load the model, do the following:\n", "# load_path = custom_model.path\n", "# custom_model.save()\n", "# del custom_model\n", "# custom_model = CustomRandomForestModel.load(path=load_path)" ] }, { "cell_type": "markdown", "id": "91978186", "metadata": {}, "source": [ "### Predict with trained model\n", "\n", "Now that the model is fit, we can make predictions on new data. Remember that we need to perform the same data and label transformations to the new data as we did to the training data." ] }, { "cell_type": "code", "execution_count": null, "id": "6b495bba", "metadata": {}, "outputs": [], "source": [ "# Prepare test data\n", "X_test_clean = feature_generator.transform(X_test)\n", "y_test_clean = label_cleaner.transform(y_test)\n", "\n", "X_test.head(5)" ] }, { "cell_type": "markdown", "id": "c65f14d4", "metadata": {}, "source": [ "Get raw predictions from the test data" ] }, { "cell_type": "code", "execution_count": null, "id": "37cd98db", "metadata": {}, "outputs": [], "source": [ "y_pred = custom_model.predict(X_test_clean)\n", "print(y_pred[:5])" ] }, { "cell_type": "markdown", "id": "93781613", "metadata": {}, "source": [ "Note that these predictions are of the positive class (whichever class was inferred to 1). To get more interpretable results, do the following:" ] }, { "cell_type": "code", "execution_count": null, "id": "a71466cc", "metadata": {}, "outputs": [], "source": [ "y_pred_orig = label_cleaner.inverse_transform(y_pred)\n", "y_pred_orig.head(5)" ] }, { "cell_type": "markdown", "id": "c862046b", "metadata": {}, "source": [ "### Score with trained model\n", "\n", "By default, the model has an eval_metric specific to the problem_type. For binary classification, it uses accuracy.\n", "\n", "We can get the accuracy score of the model by doing the following:" ] }, { "cell_type": "code", "execution_count": null, "id": "ca058bd8", "metadata": {}, "outputs": [], "source": [ "score = custom_model.score(X_test_clean, y_test_clean)\n", "print(f'Test score ({custom_model.eval_metric.name}) = {score}')" ] }, { "cell_type": "markdown", "id": "a54c31bc", "metadata": {}, "source": [ "## Training a bagged custom model without TabularPredictor\n", "\n", "Some of the more advanced functionality in AutoGluon such as bagging can be done very easily to models once they inherit from AbstractModel.\n", "\n", "You can even bag your custom model in a couple lines of code. This is a quick way to get quality improvements on nearly any model:" ] }, { "cell_type": "code", "execution_count": null, "id": "472200f9", "metadata": {}, "outputs": [], "source": [ "from autogluon.core.models import BaggedEnsembleModel\n", "bagged_custom_model = BaggedEnsembleModel(CustomRandomForestModel())\n", "# Parallel folding currently doesn't work with a class not defined in a separate module because of underlying pickle serialization issue\n", "# You don't need this following line if you put your custom model in a separate file and import it.\n", "bagged_custom_model.params['fold_fitting_strategy'] = 'sequential_local' \n", "bagged_custom_model.fit(X=X_clean, y=y_clean, k_fold=10) # Perform 10-fold bagging\n", "bagged_score = bagged_custom_model.score(X_test_clean, y_test_clean)\n", "print(f'Test score ({bagged_custom_model.eval_metric.name}) = {bagged_score} (bagged)')\n", "print(f'Bagging increased model accuracy by {round(bagged_score - score, 4) * 100}%!')" ] }, { "cell_type": "markdown", "id": "227d5c0c", "metadata": {}, "source": [ "Note that the bagged model trained 10 CustomRandomForestModels on different splits of the training data. When making a prediction, the bagged model averages the predictions from these 10 models.\n", "\n", "## Training a custom model with TabularPredictor\n", "\n", "While not using [TabularPredictor](../../../api/autogluon.tabular.TabularPredictor.rst) allows us to simplify the amount of code we need to worry about while developing and debugging our model, eventually we want to leverage TabularPredictor to get the most out of our model.\n", "\n", "The code to train the model from the raw data is very simple when using TabularPredictor. There is no need to specify a LabelCleaner, FeatureGenerator, or a validation set, all of that is handled internally.\n", "\n", "Here we train 3 CustomRandomForestModel with different hyperparameters." ] }, { "cell_type": "code", "execution_count": null, "id": "dc98be87", "metadata": {}, "outputs": [], "source": [ "from autogluon.tabular import TabularPredictor\n", "\n", "# custom_hyperparameters = {CustomRandomForestModel: {}} # train 1 CustomRandomForestModel Model with default hyperparameters\n", "custom_hyperparameters = {CustomRandomForestModel: [{}, {'max_depth': 10}, {'max_features': 0.9, 'max_depth': 20}]} # Train 3 CustomRandomForestModel with different hyperparameters\n", "predictor = TabularPredictor(label=label).fit(train_data, hyperparameters=custom_hyperparameters)" ] }, { "cell_type": "markdown", "id": "03a0ae2d", "metadata": {}, "source": [ "### Predictor leaderboard\n", "\n", "Here we show the stats of each of the models trained. Notice that a WeightedEnsemble model was also trained. This model tries to combine the predictions of the other models to get a better validation score via ensembling." ] }, { "cell_type": "code", "execution_count": null, "id": "4c8222cd", "metadata": {}, "outputs": [], "source": [ "predictor.leaderboard(test_data)" ] }, { "cell_type": "markdown", "id": "3d977d3a", "metadata": {}, "source": [ "### Predict with fit predictor\n", "\n", "Here we predict with the fit predictor. This will automatically use the best model (the one with highest score_val) to predict." ] }, { "cell_type": "code", "execution_count": null, "id": "edb6051b", "metadata": {}, "outputs": [], "source": [ "y_pred = predictor.predict(test_data)\n", "# y_pred = predictor.predict(test_data, model='CustomRandomForestModel_3') # If we want a specific model to predict\n", "y_pred.head(5)" ] }, { "cell_type": "markdown", "id": "de10a1a4", "metadata": {}, "source": [ "## Hyperparameter tuning a custom model with TabularPredictor\n", "\n", "We can easily hyperparameter tune custom models by specifying a hyperparameter search space in-place of exact values.\n", "\n", "Here we hyperparameter tune the custom model for 20 seconds:" ] }, { "cell_type": "code", "execution_count": null, "id": "679cf828", "metadata": {}, "outputs": [], "source": [ "from autogluon.common import space\n", "custom_hyperparameters_hpo = {CustomRandomForestModel: {\n", " 'max_depth': space.Int(lower=5, upper=30),\n", " 'max_features': space.Real(lower=0.1, upper=1.0),\n", " 'criterion': space.Categorical('gini', 'entropy'),\n", "}}\n", "# Hyperparameter tune CustomRandomForestModel for 20 seconds\n", "predictor = TabularPredictor(label=label).fit(train_data,\n", " hyperparameters=custom_hyperparameters_hpo,\n", " hyperparameter_tune_kwargs='auto', # enables HPO\n", " time_limit=20)" ] }, { "cell_type": "markdown", "id": "673a394f", "metadata": {}, "source": [ "### Predictor leaderboard (HPO)\n", "\n", "The leaderboard for the HPO run will show models with suffix `'/Tx'` in their name. This indicates the HPO trial they were performed in." ] }, { "cell_type": "code", "execution_count": null, "id": "7dfbafdb", "metadata": {}, "outputs": [], "source": [ "leaderboard_hpo = predictor.leaderboard()\n", "leaderboard_hpo" ] }, { "cell_type": "markdown", "id": "ac17d440", "metadata": {}, "source": [ "### Getting the hyperparameters of a trained model\n", "\n", "Let's get the hyperparameters of the model with the highest validation score." ] }, { "cell_type": "code", "execution_count": null, "id": "6f543537", "metadata": {}, "outputs": [], "source": [ "best_model_name = leaderboard_hpo[leaderboard_hpo['stack_level'] == 1]['model'].iloc[0]\n", "\n", "predictor_info = predictor.info()\n", "best_model_info = predictor_info['model_info'][best_model_name]\n", "\n", "print(best_model_info)\n", "\n", "print(f'Best Model Hyperparameters ({best_model_name}):')\n", "print(best_model_info['hyperparameters'])" ] }, { "cell_type": "markdown", "id": "81bb806c", "metadata": {}, "source": [ "## Training a custom model alongside other models with TabularPredictor\n", "\n", "Finally, we will train the custom model (with tuned hyperparameters) alongside the default AutoGluon models.\n", "\n", "All this requires is getting the hyperparameter dictionary of the default models via `get_hyperparameter_config`, and adding CustomRandomForestModel as a key." ] }, { "cell_type": "code", "execution_count": null, "id": "4ccbb2b0", "metadata": {}, "outputs": [], "source": [ "from autogluon.tabular.configs.hyperparameter_configs import get_hyperparameter_config\n", "\n", "# Now we can add the custom model with tuned hyperparameters to be trained alongside the default models:\n", "custom_hyperparameters = get_hyperparameter_config('default')\n", "\n", "custom_hyperparameters[CustomRandomForestModel] = best_model_info['hyperparameters']\n", "\n", "print(custom_hyperparameters)" ] }, { "cell_type": "code", "execution_count": null, "id": "246d72ac", "metadata": {}, "outputs": [], "source": [ "predictor = TabularPredictor(label=label).fit(train_data, hyperparameters=custom_hyperparameters) # Train the default models plus a single tuned CustomRandomForestModel\n", "# predictor = TabularPredictor(label=label).fit(train_data, hyperparameters=custom_hyperparameters, presets='best_quality') # We can even use the custom model in a multi-layer stack ensemble\n", "predictor.leaderboard(test_data)" ] }, { "cell_type": "markdown", "id": "ea8cc242", "metadata": {}, "source": [ "## Wrapping up\n", "\n", "That's all it takes to add a custom model to AutoGluon. If you create a custom model, consider [submitting a PR](https://github.com/autogluon/autogluon/pulls) so that we can add it officially to AutoGluon!\n", "\n", "For more tutorials, refer to [Predicting Columns in a Table - Quick Start](../tabular-quick-start.ipynb) and [Predicting Columns in a Table - In Depth](../tabular-indepth.ipynb).\n", "\n", "For a tutorial on advanced custom models, refer to [Adding a custom model to AutoGluon (Advanced)](tabular-custom-model-advanced.ipynb))" ] } ], "metadata": { "language_info": { "name": "python" } }, "nbformat": 4, "nbformat_minor": 5 }