{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "\n# Evaluation of outlier detection estimators\n\nThis example compares two outlier detection algorithms, namely\n`local_outlier_factor` (LOF) and `isolation_forest` (IForest), on\nreal-world datasets available in :class:`sklearn.datasets`. The goal is to show\nthat different algorithms perform well on different datasets and contrast their\ntraining speed and sensitivity to hyperparameters.\n\nThe algorithms are trained (without labels) on the whole dataset assumed to\ncontain outliers.\n\n1. The ROC curves are computed using knowledge of the ground-truth labels\nand displayed using :class:`~sklearn.metrics.RocCurveDisplay`.\n\n2. The performance is assessed in terms of the ROC-AUC.\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "# Authors: The scikit-learn developers\n# SPDX-License-Identifier: BSD-3-Clause" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Dataset preprocessing and model training\n\nDifferent outlier detection models require different preprocessing. In the\npresence of categorical variables,\n:class:`~sklearn.preprocessing.OrdinalEncoder` is often a good strategy for\ntree-based models such as :class:`~sklearn.ensemble.IsolationForest`, whereas\nneighbors-based models such as :class:`~sklearn.neighbors.LocalOutlierFactor`\nwould be impacted by the ordering induced by ordinal encoding. To avoid\ninducing an ordering, on should rather use\n:class:`~sklearn.preprocessing.OneHotEncoder`.\n\nNeighbors-based models may also require scaling of the numerical features (see\nfor instance `neighbors_scaling`). In the presence of outliers, a good\noption is to use a :class:`~sklearn.preprocessing.RobustScaler`.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "from sklearn.compose import ColumnTransformer\nfrom sklearn.ensemble import IsolationForest\nfrom sklearn.neighbors import LocalOutlierFactor\nfrom sklearn.pipeline import make_pipeline\nfrom sklearn.preprocessing import (\n OneHotEncoder,\n OrdinalEncoder,\n RobustScaler,\n)\n\n\ndef make_estimator(name, categorical_columns=None, iforest_kw=None, lof_kw=None):\n \"\"\"Create an outlier detection estimator based on its name.\"\"\"\n if name == \"LOF\":\n outlier_detector = LocalOutlierFactor(**(lof_kw or {}))\n if categorical_columns is None:\n preprocessor = RobustScaler()\n else:\n preprocessor = ColumnTransformer(\n transformers=[(\"categorical\", OneHotEncoder(), categorical_columns)],\n remainder=RobustScaler(),\n )\n else: # name == \"IForest\"\n outlier_detector = IsolationForest(**(iforest_kw or {}))\n if categorical_columns is None:\n preprocessor = None\n else:\n ordinal_encoder = OrdinalEncoder(\n handle_unknown=\"use_encoded_value\", unknown_value=-1\n )\n preprocessor = ColumnTransformer(\n transformers=[\n (\"categorical\", ordinal_encoder, categorical_columns),\n ],\n remainder=\"passthrough\",\n )\n\n return make_pipeline(preprocessor, outlier_detector)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "The following `fit_predict` function returns the average outlier score of X.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "from time import perf_counter\n\n\ndef fit_predict(estimator, X):\n tic = perf_counter()\n if estimator[-1].__class__.__name__ == \"LocalOutlierFactor\":\n estimator.fit(X)\n y_pred = estimator[-1].negative_outlier_factor_\n else: # \"IsolationForest\"\n y_pred = estimator.fit(X).decision_function(X)\n toc = perf_counter()\n print(f\"Duration for {model_name}: {toc - tic:.2f} s\")\n return y_pred" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "On the rest of the example we process one dataset per section. After loading\nthe data, the targets are modified to consist of two classes: 0 representing\ninliers and 1 representing outliers. Due to computational constraints of the\nscikit-learn documentation, the sample size of some datasets is reduced using\na stratified :class:`~sklearn.model_selection.train_test_split`.\n\nFurthermore, we set `n_neighbors` to match the expected number of anomalies\n`expected_n_anomalies = n_samples * expected_anomaly_fraction`. This is a good\nheuristic as long as the proportion of outliers is not very low, the reason\nbeing that `n_neighbors` should be at least greater than the number of samples\nin the less populated cluster (see\n`sphx_glr_auto_examples_neighbors_plot_lof_outlier_detection.py`).\n\n### KDDCup99 - SA dataset\n\nThe `kddcup99_dataset` was generated using a closed network and\nhand-injected attacks. The SA dataset is a subset of it obtained by simply\nselecting all the normal data and an anomaly proportion of around 3%.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "import numpy as np\n\nfrom sklearn.datasets import fetch_kddcup99\nfrom sklearn.model_selection import train_test_split\n\nX, y = fetch_kddcup99(\n subset=\"SA\", percent10=True, random_state=42, return_X_y=True, as_frame=True\n)\ny = (y != b\"normal.\").astype(np.int32)\nX, _, y, _ = train_test_split(X, y, train_size=0.1, stratify=y, random_state=42)\n\nn_samples, anomaly_frac = X.shape[0], y.mean()\nprint(f\"{n_samples} datapoints with {y.sum()} anomalies ({anomaly_frac:.02%})\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "The SA dataset contains 41 features out of which 3 are categorical:\n\"protocol_type\", \"service\" and \"flag\".\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "y_true = {}\ny_pred = {\"LOF\": {}, \"IForest\": {}}\nmodel_names = [\"LOF\", \"IForest\"]\ncat_columns = [\"protocol_type\", \"service\", \"flag\"]\n\ny_true[\"KDDCup99 - SA\"] = y\nfor model_name in model_names:\n model = make_estimator(\n name=model_name,\n categorical_columns=cat_columns,\n lof_kw={\"n_neighbors\": int(n_samples * anomaly_frac)},\n iforest_kw={\"random_state\": 42},\n )\n y_pred[model_name][\"KDDCup99 - SA\"] = fit_predict(model, X)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Forest covertypes dataset\n\nThe `covtype_dataset` is a multiclass dataset where the target is the\ndominant species of tree in a given patch of forest. It contains 54 features,\nsome of which (\"Wilderness_Area\" and \"Soil_Type\") are already binary encoded.\nThough originally meant as a classification task, one can regard inliers as\nsamples encoded with label 2 and outliers as those with label 4.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "from sklearn.datasets import fetch_covtype\n\nX, y = fetch_covtype(return_X_y=True, as_frame=True)\ns = (y == 2) + (y == 4)\nX = X.loc[s]\ny = y.loc[s]\ny = (y != 2).astype(np.int32)\n\nX, _, y, _ = train_test_split(X, y, train_size=0.05, stratify=y, random_state=42)\nX_forestcover = X # save X for later use\n\nn_samples, anomaly_frac = X.shape[0], y.mean()\nprint(f\"{n_samples} datapoints with {y.sum()} anomalies ({anomaly_frac:.02%})\")" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "y_true[\"forestcover\"] = y\nfor model_name in model_names:\n model = make_estimator(\n name=model_name,\n lof_kw={\"n_neighbors\": int(n_samples * anomaly_frac)},\n iforest_kw={\"random_state\": 42},\n )\n y_pred[model_name][\"forestcover\"] = fit_predict(model, X)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Ames Housing dataset\n\nThe [Ames housing dataset](http://www.openml.org/d/43926) is originally a\nregression dataset where the target are sales prices of houses in Ames, Iowa.\nHere we convert it into an outlier detection problem by regarding houses with\nprice over 70 USD/sqft. To make the problem easier, we drop intermediate\nprices between 40 and 70 USD/sqft.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "import matplotlib.pyplot as plt\n\nfrom sklearn.datasets import fetch_openml\n\nX, y = fetch_openml(name=\"ames_housing\", version=1, return_X_y=True, as_frame=True)\ny = y.div(X[\"Lot_Area\"])\n\n# None values in pandas 1.5.1 were mapped to np.nan in pandas 2.0.1\nX[\"Misc_Feature\"] = X[\"Misc_Feature\"].cat.add_categories(\"NoInfo\").fillna(\"NoInfo\")\nX[\"Mas_Vnr_Type\"] = X[\"Mas_Vnr_Type\"].cat.add_categories(\"NoInfo\").fillna(\"NoInfo\")\n\nX.drop(columns=\"Lot_Area\", inplace=True)\nmask = (y < 40) | (y > 70)\nX = X.loc[mask]\ny = y.loc[mask]\ny.hist(bins=20, edgecolor=\"black\")\nplt.xlabel(\"House price in USD/sqft\")\n_ = plt.title(\"Distribution of house prices in Ames\")" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "y = (y > 70).astype(np.int32)\n\nn_samples, anomaly_frac = X.shape[0], y.mean()\nprint(f\"{n_samples} datapoints with {y.sum()} anomalies ({anomaly_frac:.02%})\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "The dataset contains 46 categorical features. In this case it is easier use a\n:class:`~sklearn.compose.make_column_selector` to find them instead of passing\na list made by hand.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "from sklearn.compose import make_column_selector as selector\n\ncategorical_columns_selector = selector(dtype_include=\"category\")\ncat_columns = categorical_columns_selector(X)\n\ny_true[\"ames_housing\"] = y\nfor model_name in model_names:\n model = make_estimator(\n name=model_name,\n categorical_columns=cat_columns,\n lof_kw={\"n_neighbors\": int(n_samples * anomaly_frac)},\n iforest_kw={\"random_state\": 42},\n )\n y_pred[model_name][\"ames_housing\"] = fit_predict(model, X)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Cardiotocography dataset\n\nThe [Cardiotocography dataset](http://www.openml.org/d/1466) is a multiclass\ndataset of fetal cardiotocograms, the classes being the fetal heart rate (FHR)\npattern encoded with labels from 1 to 10. Here we set class 3 (the minority\nclass) to represent the outliers. It contains 30 numerical features, some of\nwhich are binary encoded and some are continuous.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "X, y = fetch_openml(name=\"cardiotocography\", version=1, return_X_y=True, as_frame=False)\nX_cardiotocography = X # save X for later use\ns = y == \"3\"\ny = s.astype(np.int32)\n\nn_samples, anomaly_frac = X.shape[0], y.mean()\nprint(f\"{n_samples} datapoints with {y.sum()} anomalies ({anomaly_frac:.02%})\")" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "y_true[\"cardiotocography\"] = y\nfor model_name in model_names:\n model = make_estimator(\n name=model_name,\n lof_kw={\"n_neighbors\": int(n_samples * anomaly_frac)},\n iforest_kw={\"random_state\": 42},\n )\n y_pred[model_name][\"cardiotocography\"] = fit_predict(model, X)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Plot and interpret results\n\nThe algorithm performance relates to how good the true positive rate (TPR) is\nat low value of the false positive rate (FPR). The best algorithms have the\ncurve on the top-left of the plot and the area under curve (AUC) close to 1.\nThe diagonal dashed line represents a random classification of outliers and\ninliers.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "import math\n\nfrom sklearn.metrics import RocCurveDisplay\n\ncols = 2\npos_label = 0 # mean 0 belongs to positive class\ndatasets_names = y_true.keys()\nrows = math.ceil(len(datasets_names) / cols)\n\nfig, axs = plt.subplots(nrows=rows, ncols=cols, squeeze=False, figsize=(10, rows * 4))\n\nfor ax, dataset_name in zip(axs.ravel(), datasets_names):\n for model_idx, model_name in enumerate(model_names):\n display = RocCurveDisplay.from_predictions(\n y_true[dataset_name],\n y_pred[model_name][dataset_name],\n pos_label=pos_label,\n name=model_name,\n ax=ax,\n plot_chance_level=(model_idx == len(model_names) - 1),\n chance_level_kw={\"linestyle\": \":\"},\n )\n ax.set_title(dataset_name)\n_ = plt.tight_layout(pad=2.0) # spacing between subplots" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We observe that once the number of neighbors is tuned, LOF and IForest perform\nsimilarly in terms of ROC AUC for the forestcover and cardiotocography\ndatasets. The score for IForest is slightly better for the SA dataset and LOF\nperforms considerably better on the Ames housing dataset than IForest.\n\nRecall however that Isolation Forest tends to train much faster than LOF on\ndatasets with a large number of samples. LOF needs to compute pairwise\ndistances to find nearest neighbors, which has a quadratic complexity with respect\nto the number of observations. This can make this method prohibitive on large\ndatasets.\n\n## Ablation study\n\nIn this section we explore the impact of the hyperparameter `n_neighbors` and\nthe choice of scaling the numerical variables on the LOF model. Here we use\nthe `covtype_dataset` dataset as the binary encoded categories introduce\na natural scale of euclidean distances between 0 and 1. We then want a scaling\nmethod to avoid granting a privilege to non-binary features and that is robust\nenough to outliers so that the task of finding them does not become too\ndifficult.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "X = X_forestcover\ny = y_true[\"forestcover\"]\n\nn_samples = X.shape[0]\nn_neighbors_list = (n_samples * np.array([0.2, 0.02, 0.01, 0.001])).astype(np.int32)\nmodel = make_pipeline(RobustScaler(), LocalOutlierFactor())\n\nlinestyles = [\"solid\", \"dashed\", \"dashdot\", \":\", (5, (10, 3))]\n\nfig, ax = plt.subplots()\nfor model_idx, (linestyle, n_neighbors) in enumerate(zip(linestyles, n_neighbors_list)):\n model.set_params(localoutlierfactor__n_neighbors=n_neighbors)\n model.fit(X)\n y_pred = model[-1].negative_outlier_factor_\n display = RocCurveDisplay.from_predictions(\n y,\n y_pred,\n pos_label=pos_label,\n name=f\"n_neighbors = {n_neighbors}\",\n ax=ax,\n plot_chance_level=(model_idx == len(n_neighbors_list) - 1),\n chance_level_kw={\"linestyle\": (0, (1, 10))},\n linestyle=linestyle,\n linewidth=2,\n )\n_ = ax.set_title(\"RobustScaler with varying n_neighbors\\non forestcover dataset\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We observe that the number of neighbors has a big impact on the performance of\nthe model. If one has access to (at least some) ground truth labels, it is\nthen important to tune `n_neighbors` accordingly. A convenient way to do so is\nto explore values for `n_neighbors` of the order of magnitud of the expected\ncontamination.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "from sklearn.preprocessing import MinMaxScaler, SplineTransformer, StandardScaler\n\npreprocessor_list = [\n None,\n RobustScaler(),\n StandardScaler(),\n MinMaxScaler(),\n SplineTransformer(),\n]\nexpected_anomaly_fraction = 0.02\nlof = LocalOutlierFactor(n_neighbors=int(n_samples * expected_anomaly_fraction))\n\nfig, ax = plt.subplots()\nfor model_idx, (linestyle, preprocessor) in enumerate(\n zip(linestyles, preprocessor_list)\n):\n model = make_pipeline(preprocessor, lof)\n model.fit(X)\n y_pred = model[-1].negative_outlier_factor_\n display = RocCurveDisplay.from_predictions(\n y,\n y_pred,\n pos_label=pos_label,\n name=str(preprocessor).split(\"(\")[0],\n ax=ax,\n plot_chance_level=(model_idx == len(preprocessor_list) - 1),\n chance_level_kw={\"linestyle\": (0, (1, 10))},\n linestyle=linestyle,\n linewidth=2,\n )\n_ = ax.set_title(\"Fixed n_neighbors with varying preprocessing\\non forestcover dataset\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "On the one hand, :class:`~sklearn.preprocessing.RobustScaler` scales each\nfeature independently by using the interquartile range (IQR) by default, which\nis the range between the 25th and 75th percentiles of the data. It centers the\ndata by subtracting the median and then scale it by dividing by the IQR. The\nIQR is robust to outliers: the median and interquartile range are less\naffected by extreme values than the range, the mean and the standard\ndeviation. Furthermore, :class:`~sklearn.preprocessing.RobustScaler` does not\nsquash marginal outlier values, contrary to\n:class:`~sklearn.preprocessing.StandardScaler`.\n\nOn the other hand, :class:`~sklearn.preprocessing.MinMaxScaler` scales each\nfeature individually such that its range maps into the range between zero and\none. If there are outliers in the data, they can skew it towards either the\nminimum or maximum values, leading to a completely different distribution of\ndata with large marginal outliers: all non-outlier values can be collapsed\nalmost together as a result.\n\nWe also evaluated no preprocessing at all (by passing `None` to the pipeline),\n:class:`~sklearn.preprocessing.StandardScaler` and\n:class:`~sklearn.preprocessing.SplineTransformer`. Please refer to their\nrespective documentation for more details.\n\nNote that the optimal preprocessing depends on the dataset, as shown below:\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "X = X_cardiotocography\ny = y_true[\"cardiotocography\"]\n\nn_samples, expected_anomaly_fraction = X.shape[0], 0.025\nlof = LocalOutlierFactor(n_neighbors=int(n_samples * expected_anomaly_fraction))\n\nfig, ax = plt.subplots()\nfor model_idx, (linestyle, preprocessor) in enumerate(\n zip(linestyles, preprocessor_list)\n):\n model = make_pipeline(preprocessor, lof)\n model.fit(X)\n y_pred = model[-1].negative_outlier_factor_\n display = RocCurveDisplay.from_predictions(\n y,\n y_pred,\n pos_label=pos_label,\n name=str(preprocessor).split(\"(\")[0],\n ax=ax,\n plot_chance_level=(model_idx == len(preprocessor_list) - 1),\n chance_level_kw={\"linestyle\": (0, (1, 10))},\n linestyle=linestyle,\n linewidth=2,\n )\nax.set_title(\n \"Fixed n_neighbors with varying preprocessing\\non cardiotocography dataset\"\n)\nplt.show()" ] } ], "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.9.21" } }, "nbformat": 4, "nbformat_minor": 0 }