From 92d2d169be617119e50a3921944c2a01d036f319 Mon Sep 17 00:00:00 2001 From: Yuval Uner Date: Mon, 10 Feb 2025 16:46:59 +0200 Subject: [PATCH 1/4] Migrated outlier explainer from fedex to this repository --- README.md | 10 +- .../notebooks/Outlier explainer demo.ipynb | 380 +++++++++++++++++ requirements.txt | 3 + setup.py | 42 ++ src/external_explainers/__init__.py | 1 + src/external_explainers/commons/__init__.py | 1 + src/external_explainers/commons/utils.py | 25 ++ .../outlier_explainer/__init__.py | 0 .../outlier_explainer/outlier_explainer.py | 394 ++++++++++++++++++ 9 files changed, 855 insertions(+), 1 deletion(-) create mode 100644 examples/notebooks/Outlier explainer demo.ipynb create mode 100644 requirements.txt create mode 100644 setup.py create mode 100644 src/external_explainers/__init__.py create mode 100644 src/external_explainers/commons/__init__.py create mode 100644 src/external_explainers/commons/utils.py create mode 100644 src/external_explainers/outlier_explainer/__init__.py create mode 100644 src/external_explainers/outlier_explainer/outlier_explainer.py diff --git a/README.md b/README.md index 6ac82de..e325410 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,10 @@ # ExternalExplainers -External explainer implementations for PD-EXPLAIN +External explainer implementations for [PD-EXPLAIN](https://github.com/analysis-bots/pd-explain).\ +While you can use the explainer implementations in this repository directly, it is recommended to use them through the PD-EXPLAIN library, +for a much better and more user-friendly experience. +## Included Explainers +### Outlier explainer +This explainer is based on the [SCORPION](https://sirrice.github.io/files/papers/scorpion-vldb13.pdf) paper.\ +Its goal is to provide explanations for outliers in the data, explaining why a certain data point is an outlier.\ +This explainer is meant to work on series created as a result of groupby + aggregation operations.\ +Explainer author: [@Itay Elyashiv](https://github.com/ItayELY) diff --git a/examples/notebooks/Outlier explainer demo.ipynb b/examples/notebooks/Outlier explainer demo.ipynb new file mode 100644 index 0000000..04d3260 --- /dev/null +++ b/examples/notebooks/Outlier explainer demo.ipynb @@ -0,0 +1,380 @@ +{ + "cells": [ + { + "metadata": {}, + "cell_type": "markdown", + "source": "# Outlier explainer demo", + "id": "e604881e784a9f9a" + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "This notebook demonstrates the usage of the outlier explainer.\\\n", + "Note that it is recommended to use our other package, pd-explain, for better ease of use and more features." + ], + "id": "ca1fceec8df719a6" + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "## Imports and loading data", + "id": "2ac929f2c20f6248" + }, + { + "cell_type": "code", + "id": "initial_id", + "metadata": { + "collapsed": true, + "ExecuteTime": { + "end_time": "2025-02-10T14:28:34.539967Z", + "start_time": "2025-02-10T14:28:33.726878Z" + } + }, + "source": [ + "import pandas as pd\n", + "from external_explainers import OutlierExplainer" + ], + "outputs": [], + "execution_count": 1 + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-02-10T14:28:37.085917Z", + "start_time": "2025-02-10T14:28:34.548929Z" + } + }, + "cell_type": "code", + "source": [ + "spotify_data = pd.read_csv('https://raw.githubusercontent.com/analysis-bots/pd-explain/refs/heads/main/Examples/Datasets/spotify_all.csv')\n", + "spotify_data.head()" + ], + "id": "4a877eb58c908b7d", + "outputs": [ + { + "data": { + "text/plain": [ + " acousticness artists danceability energy explicit \\\n", + "0 0.991000 ['Mamie Smith'] 0.598 0.224 0 \n", + "1 0.643000 [\"Screamin' Jay Hawkins\"] 0.852 0.517 0 \n", + "2 0.993000 ['Mamie Smith'] 0.647 0.186 0 \n", + "3 0.000173 ['Oscar Velazquez'] 0.730 0.798 0 \n", + "4 0.295000 ['Mixe'] 0.704 0.707 1 \n", + "\n", + " id instrumentalness key liveness loudness ... \\\n", + "0 0cS0A1fUEUd1EW3FcF8AEI 0.000522 5 0.3790 -12.628 ... \n", + "1 0hbkKFIJm7Z05H8Zl9w30f 0.026400 5 0.0809 -7.261 ... \n", + "2 11m7laMUgmOKqI3oYzuhne 0.000018 0 0.5190 -12.098 ... \n", + "3 19Lc5SfJJ5O1oaxY0fpwfh 0.801000 2 0.1280 -7.311 ... \n", + "4 2hJjbsLCytGsnAHfdsLejp 0.000246 10 0.4020 -6.036 ... \n", + "\n", + " name popularity speechiness \\\n", + "0 Keep A Song In Your Soul 12 0.0936 \n", + "1 I Put A Spell On You 7 0.0534 \n", + "2 Golfing Papa 4 0.1740 \n", + "3 True House Music - Xavier Santos & Carlos Gomi... 17 0.0425 \n", + "4 Xuniverxe 2 0.0768 \n", + "\n", + " tempo valence year decade popularity_score main_artist \\\n", + "0 149.976 0.6340 1920 1920 10 Mamie Smith \n", + "1 86.889 0.9500 1920 1920 0 Screamin' Jay Hawkins \n", + "2 97.600 0.6890 1920 1920 0 Mamie Smith \n", + "3 127.997 0.0422 1920 1920 10 Oscar Velazquez \n", + "4 122.076 0.2990 1920 1920 0 Mixe \n", + "\n", + " duration_minutes \n", + "0 2.805550 \n", + "1 2.503333 \n", + "2 2.730450 \n", + "3 7.034783 \n", + "4 2.753733 \n", + "\n", + "[5 rows x 21 columns]" + ], + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
acousticnessartistsdanceabilityenergyexplicitidinstrumentalnesskeylivenessloudness...namepopularityspeechinesstempovalenceyeardecadepopularity_scoremain_artistduration_minutes
00.991000['Mamie Smith']0.5980.22400cS0A1fUEUd1EW3FcF8AEI0.00052250.3790-12.628...Keep A Song In Your Soul120.0936149.9760.63401920192010Mamie Smith2.805550
10.643000[\"Screamin' Jay Hawkins\"]0.8520.51700hbkKFIJm7Z05H8Zl9w30f0.02640050.0809-7.261...I Put A Spell On You70.053486.8890.9500192019200Screamin' Jay Hawkins2.503333
20.993000['Mamie Smith']0.6470.186011m7laMUgmOKqI3oYzuhne0.00001800.5190-12.098...Golfing Papa40.174097.6000.6890192019200Mamie Smith2.730450
30.000173['Oscar Velazquez']0.7300.798019Lc5SfJJ5O1oaxY0fpwfh0.80100020.1280-7.311...True House Music - Xavier Santos & Carlos Gomi...170.0425127.9970.04221920192010Oscar Velazquez7.034783
40.295000['Mixe']0.7040.70712hJjbsLCytGsnAHfdsLejp0.000246100.4020-6.036...Xuniverxe20.0768122.0760.2990192019200Mixe2.753733
\n", + "

5 rows × 21 columns

\n", + "
" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 2 + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "## Outlier detection", + "id": "84056d9e106e6990" + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "The outlier explainer is meant for usage on series that are a result of aggregation operations.\\\n", + "First, we perform a groupby and aggregation on the data." + ], + "id": "2c10b272c0aaa9ce" + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-02-10T14:28:37.372620Z", + "start_time": "2025-02-10T14:28:37.345307Z" + } + }, + "cell_type": "code", + "source": [ + "new_songs_df = spotify_data[spotify_data['year'] >= 1990]\n", + "gb_decade = new_songs_df.groupby('decade')['popularity'].mean()\n", + "gb_decade" + ], + "id": "c38c258934cdca14", + "outputs": [ + { + "data": { + "text/plain": [ + "decade\n", + "1990 43.120769\n", + "2000 43.167320\n", + "2010 29.579203\n", + "2020 19.171014\n", + "Name: popularity, dtype: float64" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 3 + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "Now we can the outlier to explain suspected outliers.", + "id": "36ce2fef356a95ba" + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-02-10T14:29:22.957533Z", + "start_time": "2025-02-10T14:29:21.682728Z" + } + }, + "cell_type": "code", + "source": [ + "outlier_explainer = OutlierExplainer()\n", + "outlier_explainer.explain(df_agg=gb_decade, df_in=new_songs_df, g_att='decade', g_agg='popularity', agg_method='mean', target=2020, dir=-1)" + ], + "id": "545e243e9f81631a", + "outputs": [ + { + "data": { + "text/plain": [ + "
" + ], + "image/png": "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YYKIiIhkYZggIiIiWRgmiIiISBaGCSIiIpKFYYKIiIhkYZggIiIiWRgmiIiISBbDwix069YtHDhwADExMVCr1RrTJk2apJPCiIiIqHTQOkysWLECw4cPh4ODA5ycnKBQKKRpCoWCYYKIiKiM0TpMzJgxA19//TW++OKLoqiHiIiIShmt+0zExcWhZ8+eRVELERERlUJah4mePXtiz549RVELERERlUJan+aoVq0aJk6ciBMnTsDb2xtGRkYa00ePHq2z4oiIiKjk0zpMLF++HBYWFjh48CAOHjyoMU2hUDBMEBERlTFah4nw8PCiqIOIiIhKKV60ioiIiGQp1EWrHj58iL///hsRERFIT0/XmPb999/rpDAiIiIqHbQOE/v27UOXLl1QtWpV3LhxA15eXrh37x6EEKhfv35R1EhEREQlmNanOUJCQjB+/HhcvnwZJiYm+OOPP/DgwQM0b96c158gIiIqg7QOE9evX8eAAQMAAIaGhnjx4gUsLCwwbdo0fPvttzovkIiIiEo2rcOEubm51E+iQoUKuHPnjjTt6dOnuquMiIiISgWt+0w0btwYR44cgaenJzp27Ihx48bh8uXL+PPPP9G4ceOiqJGIiIhKMK3DxPfff4/k5GQAwNSpU5GcnIzffvsN1atX50gOIiKiMkjrMFG1alXp/+bm5vjxxx91WhARERGVLoW6aFV8fDxWrlyJkJAQxMbGAgDOnTuHR48e6bQ4IiIiKvm0PjJx6dIltGnTBtbW1rh37x4++eQT2NnZ4c8//0RERAR+/vnnoqiTiIiISiitj0wEBwcjMDAQt27dgomJidTesWNHHDp0SKfFERERUcmndZg4ffo0hg0blqu9YsWKiIqK0klRREREVHpoHSZUKhUSExNztd+8eRPlypXTSVFERERUemgdJrp06YJp06YhIyMDAKBQKBAREYEvvvgC3bt313mBREREVLJpHSbmzp2L5ORklC9fHi9evEDz5s1RrVo1WFpa4uuvvy6KGomIiKgE03o0h7W1Nfbu3YsjR47g0qVLSE5ORv369dGmTZuiqI+IiIhKOK3DRI4mTZqgSZMmuqyFiIiISqFChYnTp0/jwIEDiImJgVqt1pjGS2oTERGVLVqHiW+++QZfffUVatasCUdHRygUCmnay/8nIiKiskHrMLFgwQL89NNPCAwMLIJyiIiIqLTR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GCaIiIhIFoYJIiIikoVhgoiIiGRhmCAiIiJZGCaIiIhIFoYJIiIikoVhgoiIiGRhmCAiIiJZGCaIiIhIFoYJIiIikkWvYeLQoUPo3LkznJ2doVAosGXLFo3pQghMmjQJFSpUgKmpKdq0aYNbt27pp1giIiLKk17DREpKCurUqYMlS5bkOX327NlYuHAhfvzxR5w8eRLm5uYICAhAampqMVdKRERE+THU58Y7dOiADh065DlNCIH58+fjq6++wvvvvw8A+Pnnn+Ho6IgtW7agT58+eS6XlpaGtLQ06XFiYqLuCyciIiJJie0zER4ejqioKLRp00Zqs7a2RqNGjXD8+PF8l5s5cyasra2lHxcXl+Iol4iIqMwqsWEiKioKAODo6KjR7ujoKE3LS0hICBISEqSfBw8eFGmdREREZZ1eT3MUBZVKBZVKpe8yiIiIyowSe2TCyckJABAdHa3RHh0dLU0jIiIi/SuxYcLNzQ1OTk7Yt2+f1JaYmIiTJ0/Cz89Pj5URERHRy/R6miM5ORm3b9+WHoeHh+PChQuws7ND5cqVERQUhBkzZqB69epwc3PDxIkT4ezsjK5du+qvaCIiItKg1zBx5swZtGzZUnocHBwMABg4cCDWrFmDzz//HCkpKRg6dCji4+PRpEkT7Nq1CyYmJvoqmYiIiF6h1zDRokULCCHyna5QKDBt2jRMmzatGKsiIiIibZTYPhNERERUOjBMEBERkSwME0RERCQLwwQRERHJwjBBREREsjBMEBERkSwME0RERCQLwwQRERHJwjBBREREsjBMEBERkSwME0RERCQLwwQRERHJwjBBREREsjBMEBERkSwME0RERCQLwwQRERHJwjBBREREsjBMEBERkSwME0RERCQLwwQRERHJwjBBREREsjBMEBERkSwME0RERCQLwwQRERHJwjBBREREsjBMEBERkSwME0RERCQLwwQRERHJwjBBREREsjBMEBERkSwME0RERCQLwwQRERHJwjBBREREsjBMEBERkSwME0RERCQLwwQRERHJwjBBREREsjBMEBERkSwME0RERCQLwwQRERHJwjBBREREsjBMEBERkSwME0RERCQLwwQRERHJwjBBREREsjBMEBERkSwME0RERCQLwwQRERHJwjBBREREsjBMEBERkSwME0RERCQLwwQRERHJwjBBREREsjBMEBER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" + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "execution_count": 5 + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "Once again, we recommend doing this via pd-explain for a more streamlined experience, where most of these parameters are automatically inferred via the user's previous actions.", + "id": "4e3338ab3267da02" + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 2 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython2", + "version": "2.7.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..62768ef --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +numpy~=2.1.3 +pandas~=2.2.3 +matplotlib~=3.9.2 \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..7b7f978 --- /dev/null +++ b/setup.py @@ -0,0 +1,42 @@ +import os + +from setuptools import setup, find_packages + + +def read(rel_path): + here = os.path.abspath(os.path.dirname(__file__)) + with open(os.path.join(here, rel_path), 'r') as fp: + return fp.read() + + +def get_version(): + for line in read('src/fedex_generator/__init__.py').splitlines(): + if line.startswith('__version__'): + delim = '"' if '"' in line else "'" + return line.split(delim)[1] + else: + raise RuntimeError("Unable to find version string.") + + +def get_long_description(): + with open('README.md', 'r') as fh: + return fh.read() + + +setup( + name='external_explainers', + version='1.0.0',#get_version(), + package_dir={'': 'src'}, + packages=find_packages(where='src'), + long_description_content_type="text/markdown", + long_description=get_long_description(), # Long description read from the readme file + project_urls={ + 'Git': 'https://github.com/analysis-bots/ExternalExplainers', + }, + install_requires=[ + 'wheel', + 'pandas', + 'numpy', + 'matplotlib', + ] +) diff --git a/src/external_explainers/__init__.py b/src/external_explainers/__init__.py new file mode 100644 index 0000000..562c41a --- /dev/null +++ b/src/external_explainers/__init__.py @@ -0,0 +1 @@ +from external_explainers.outlier_explainer.outlier_explainer import OutlierExplainer \ No newline at end of file diff --git a/src/external_explainers/commons/__init__.py b/src/external_explainers/commons/__init__.py new file mode 100644 index 0000000..718fc6a --- /dev/null +++ b/src/external_explainers/commons/__init__.py @@ -0,0 +1 @@ +from external_explainers.commons import utils \ No newline at end of file diff --git a/src/external_explainers/commons/utils.py b/src/external_explainers/commons/utils.py new file mode 100644 index 0000000..50fd8fe --- /dev/null +++ b/src/external_explainers/commons/utils.py @@ -0,0 +1,25 @@ +def to_valid_latex(string, is_bold: bool = False) -> str: + """ + Convert a string to a valid latex string. + :param string: The input string. + :param is_bold: Whether the string should be bold. + :return: The latex string. + """ + latex_string = str(string) # unicode_to_latex(string) + # Choose the space character based on whether the string should be bold. + space = r'\ ' if is_bold else ' ' + # Escape special characters and replace spaces with the chosen space character. + final_str = latex_string.replace("&", "\\&").replace("#", "\\#").replace(' ', space).replace("_", space) + return final_str + + +def to_valid_latex_with_escaped_dollar_char(string, is_bold: bool = False) -> str: + """ + Convert a string to a valid latex string. + This function adds the $ character to the list of characters that are escaped, while the function to_valid_latex + does not escape the $ character. Other than that, the two functions are identical. + :param string: The input string. + :param is_bold: Whether the string should be bold. + :return: The latex string. + """ + return to_valid_latex(string, is_bold).replace("$", "\\$") \ No newline at end of file diff --git a/src/external_explainers/outlier_explainer/__init__.py b/src/external_explainers/outlier_explainer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/external_explainers/outlier_explainer/outlier_explainer.py b/src/external_explainers/outlier_explainer/outlier_explainer.py new file mode 100644 index 0000000..7e0f896 --- /dev/null +++ b/src/external_explainers/outlier_explainer/outlier_explainer.py @@ -0,0 +1,394 @@ +from matplotlib import pyplot as plt +import numpy as np +import pandas as pd +from pandas.core.interchange.dataframe_protocol import DataFrame +from typing import List, Tuple, Any +from concurrent.futures import ThreadPoolExecutor, as_completed + +from external_explainers.commons import utils + +ALPHA = 0.9 +START_BOLD = r'$\bf{' +END_BOLD = '}$' + +HIGH = 1 +LOW = -1 + + +# BETA = 0.1 +def proportion(series): + c = series.count() + return (series.count()) / (series.count().sum()) + + +class OutlierExplainer: + """ + A class for computing the outlier measure and explaining outliers.\n + This is based on the article "Scorpion: Explaining Away Outliers in Aggregate Queries" by Eugene Wu, Samuel Madden.\n + The influence of a predicate :math:`p` on an output result :math:`o` is depends on:\n + .. math:: \\Delta_{agg} (o,p) = agg(g_0) - agg(g_0 - p(g_0))\n + Where: \n + - :math:`agg` is the aggregation function.\n + - :math:`g_0` is a set of input tuples.\n + In other words, it is the difference between the original result and the result after removing the tuples that satisfy the predicate.\n + From which we define the influence as:\n + .. math:: inf_{agg}(o,p) = \\frac{\\Delta_{agg}(o,p)}{\\Delta g_0} = \\frac{\\Delta_{agg}(o,p)}{|p(g_0)|}\n + We then include the error direction, :math:`d`, to the influence calculation:\n + .. math:: inf_{agg}(o,p, d) = d \\ * \\ inf_{agg}(o,p) \n + Next, the user may select a hold-out result, :math:`h`, that the returned predicate should not influence.\n + Intuitively, :math:`p` should be penalized if it influences the hold-out results in any way.\n + The influence with the hold-out set is defined as:\n + .. math:: inf_{agg}(o,p,d,h) = \\lambda \\ * \\ inf_{agg}(o,p,d) - (1 - \\lambda) \\ * \\ inf_{agg}(h,p)\n + Where:\n + - :math:`\\lambda` is a parameter that represents the importance of not changing the value of the hold-out set. + In this project we use :math:`\\lambda = 0.9`.\n + """ + + def __init__(self): + super().__init__() + + def calc_influence_pred(self, df_before: DataFrame, df_after: DataFrame, target: str, dir: int) -> float: + """ + Calculate the influence of a predicate on a given target attribute. + + This function computes the influence of a target attribute by comparing its values + before and after some transformation, adjusted by a direction factor. It also + considers the influence of other attributes in the DataFrame. + + :param df_before: DataFrame containing the data before the transformation. + :param df_after: DataFrame containing the data after the transformation. + :param target (str)) The target attribute for which the influence is being calculated. + :param dir: Direction factor, either 1 or -1, indicating the direction of the outlier. 1 for high, -1 for low. + + :return: The calculated influence score. Returns -1 if an error occurs during calculation. + """ + try: + # Compute target influence - the ratio between the change in the output and the number of + # tuples that satisfy the predicate, multiplied by the direction factor. + target_inf = ((df_before[target] - df_after[target]) * dir) / (df_before[target] + df_after[target]) + except: + return -1 + + # Compute the holdout influence - the sum of the square root of the absolute difference between the + # values of the target attribute for each tuple in the DataFrame before and after the transformation. + holdout_inf = 0 + for i in df_before.index: + if i != target: + try: + holdout_inf += np.sqrt(abs(df_after[i] - df_before[i])) + except: + return -1 + # Return the final influence score, calculated as a weighted sum of the target and holdout influences. + return ALPHA * (target_inf) - (1 - ALPHA) * (holdout_inf / (len(df_before.index))) + + def merge_preds(self, df_agg: DataFrame, df_in: DataFrame, df_in_consider: DataFrame, + preds: List[Tuple[str, Tuple[float, float], float, str, int | None]], g_att: str, g_agg: str, + agg_method: str, target, dir: int) -> tuple[ + list[tuple[str, tuple[float, float], int | None]], float, Any | None]: + """ + Merge predicates to find the most influential attributes. + + This function iterates over a list of predicates, applying filters to the input DataFrame to exclude + certain rows based on the predicates. It calculates the influence of each predicate and keeps track + of the most influential ones. The final influence score and the filtered DataFrame are returned. + + :param df_agg: DataFrame containing the aggregated data. + :param df_in: DataFrame containing the input data. + :param df_in_consider: DataFrame containing the data to be considered for filtering. + :param preds: A list of tuples containing predicates with attribute name, bin or value, score, kind, and rank. + :param g_att: The grouping attribute. + :param g_agg: The aggregation attribute. + :param agg_method: The aggregation method to be used. + :param target: The target attribute for which the influence is being calculated. + :param dir: Direction factor, either 1 or -1, indicating the direction of the outlier. 1 for high, -1 for low. + + :return: A tuple containing a list of final predicates, the final influence score, and the final aggregated DataFrame. + """ + # Initialize variables to store the final predicates, influence score, and aggregated DataFrame. + final_pred = [] + final_inf = 0.001 + final_agg_df = None + final_filter = False + final_filter_df = False + + prev_attrs = [] + + for p in preds: + attr, i, score, kind, rank = p + + # Avoid going over previously seen attributes + if attr in prev_attrs: + continue + prev_attrs.append(attr) + + # If the kind is 'bin', we use the bin values to filter the DataFrame. + if kind == 'bin': + bin = i + final_filter_test = final_filter | ((df_in_consider[attr] < bin[0]) | (df_in_consider[attr] >= bin[1])) + final_filter_df_test = final_filter_df | ((df_in[attr] < bin[0]) | (df_in[attr] >= bin[1])) + # Otherwise, we use the attribute value to filter the DataFrame. + else: + final_filter_test = final_filter | (df_in_consider[attr] != i) + final_filter_df_test = final_filter_df | (df_in[attr] != i) + + # Apply the filter to the input DataFrame and the aggregated DataFrame. + df_exc_consider = df_in_consider[final_filter_test] + df_exc_final = df_in[final_filter_df_test] + + # Perform the aggregation operation on the filtered DataFrames. + agged_val_consider = df_exc_consider.groupby(g_att)[g_agg].agg(agg_method) + agged_val = df_exc_final.groupby(g_att)[g_agg].agg(agg_method) + + # Normalize the aggregated values if the aggregation method is 'count'. + if agg_method == 'count': + agged_val_consider = agged_val_consider / agged_val_consider.sum() + agged_val = agged_val / agged_val.sum() + + # Compute the influence score for the predicate. + inf = self.calc_influence_pred(df_agg, agged_val_consider, target, dir) / pow( + (df_in_consider.shape[0] / (df_exc_consider.shape[0] + 1)), 2) + + # If the influence score is greater by a factor of at least 1.3, update the final predicates. + if inf / final_inf > 1.3: + final_pred.append((attr, i, rank)) + final_inf = inf + + final_agg_df = agged_val + final_filter = final_filter_test + final_filter_df = final_filter_df_test + # If the influence score is not much higher, break the loop. + else: + break + return final_pred, final_inf, final_agg_df + + + def compute_predicates_per_attribute(self, attr: str, df_in: DataFrame, g_att: str, + g_agg: str, agg_method: str, target: str, dir: int, + df_in_consider: DataFrame, df_agg_consider: DataFrame) -> List[Tuple[str, Any, float, str, int]]: + """ + Compute predicates for a given attribute. + + This function calculates the influence of various predicates on a target attribute + by iterating over the values or bins of the given attribute. It generates a list of + predicates with their corresponding influence scores. + + :param attr: The attribute for which predicates are being computed. + :param df_in: DataFrame containing the input data. + :param g_att: The grouping attribute. + :param g_agg: The aggregation attribute. + :param agg_method: The aggregation method to be used. + :param target: The target attribute for which the influence is being calculated. + :param dir: Direction factor, either 1 or -1, indicating the direction of the outlier. + :param df_in_consider: DataFrame containing the data to be considered for filtering. + :param df_agg_consider: DataFrame containing the aggregated data to be considered. + + :return: A list of predicates with their influence scores. + """ + dtype = df_in[attr].dtype.name + predicates = [] + exps = {} + + # Ignore attributes with high correlation to the target attribute. + if dtype in ['int64', 'float64']: + if (df_in[g_att].dtype.name in ['int64', 'float64'] and df_in[g_att].corr(df_in[attr]) > 0.7) or ( + df_in[g_agg].dtype.name in ['int64', 'float64'] and df_in[g_agg].corr(df_in[attr]) > 0.7): + return [] + + # Get the series for the attribute and its data type. + series = df_in[attr] + dtype = df_in[attr].dtype.name + flag = False + df_in_consider_attr = df_in_consider[[g_att, g_agg, attr]] + + # If the data type is not 'float64', calculate the influence score for each value of the attribute. + if dtype not in ['float64']: + vals = series.value_counts() + if dtype != 'int64' or len(vals) < 20: + # Skip attributes with more than 50 unique values, as they are too computationally expensive. + if len(vals) > 50: + return [] + flag = True + + for i in vals.index: + + # Exclude rows with the current value of the attribute. + df_in_target_exc = df_in_consider_attr[(df_in_consider_attr[attr] != i)] + # Aggregate the values for the excluded rows. + agged_val = df_in_target_exc.groupby(g_att)[g_agg].agg(agg_method) + if agg_method == 'count': + agged_val = agged_val / agged_val.sum() + + # Calculate the influence score for the predicate. + inf = self.calc_influence_pred(df_agg_consider, agged_val, target, dir) / ( + (df_in_consider.shape[0] / (df_in_target_exc.shape[0] + 0.01))) + + exps[(attr, i)] = inf + predicates.append((attr, i, inf, 'cat', None)) + + n_bins = 20 + if not flag: + _, bins = pd.cut(series, n_bins, retbins=True, duplicates='drop') + df_bins_in = pd.cut(df_in_consider_attr[attr], bins=bins).value_counts( + normalize=True).sort_index() # .rename('idx') + i = 1 + for bin in df_bins_in.keys(): + new_bin = (float("{:.2f}".format(bin.left)), float("{:.2f}".format(bin.right))) + df_in_exc = df_in_consider_attr[ + ((df_in_consider_attr[attr] < new_bin[0]) | (df_in_consider_attr[attr] >= new_bin[1]))] + agged_val = df_in_exc.groupby(g_att)[g_agg].agg(agg_method) + if agg_method == 'count': + agged_val = agged_val / agged_val.sum() + + # Calculate the influence score for the predicate. + inf = self.calc_influence_pred(df_agg_consider, agged_val, target, dir) / ( + (df_in_consider_attr.shape[0] / df_in_exc.shape[0]) + 1) + + # Store the influence score for the predicate. + exps[(attr, (new_bin[0], new_bin[1]))] = inf + + # Add the predicate to the list of predicates. + predicates.append((attr, new_bin, inf, 'bin', i)) + i += 1 + + return predicates + + def draw_bar_plot(self, df_agg: DataFrame, final_df: DataFrame, g_att: str, g_agg: str, final_pred_by_attr: dict, + target: str, agg_title: str) -> None: + """ + Draw a bar plot to visualize the influence of predicates on the target attribute. + + This function generates a bar plot comparing the aggregated values of the target attribute + before and after applying the most influential predicates. It highlights the differences + and provides an explanation for the outlier. + + :param df_agg: DataFrame containing the aggregated data. + :param final_df: DataFrame containing the final aggregated data after applying predicates. + :param g_att: The grouping attribute. + :param g_agg: The aggregation attribute. + :param final_pred_by_attr: Dictionary containing the final predicates grouped by attribute. + :param target: The target attribute for which the influence is being visualized. + :param agg_title: Title for the aggregation method used in the plot. + + :return: None. Displays the bar plot. + """ + fig, ax = plt.subplots(layout='constrained', figsize=(5, 5)) + x1 = list(df_agg.index) + ind1 = np.arange(len(x1)) + y1 = df_agg.values + + x2 = list(final_df.index) + ind2 = np.arange(len(x2)) + y2 = final_df.values + + explanation = f'This outlier is not as significant when excluding rows with:\n' + for_wizard = '' + for a, bins in final_pred_by_attr.items(): + for b in bins: + if type(b[0]) is tuple: + pred = f"{b[0][0]} < {a} < {b[0][1]}" + inter_exp = r'$\bf{{{}}}$'.format(utils.to_valid_latex(pred)) + else: + pred = f"{a}={b[0]}" + inter_exp = r'$\bf{{{}}}$'.format(utils.to_valid_latex(pred)) + if b[1] is not None: + if b[1] <= 5: + inter_exp = inter_exp + '-' + r'$\bf{low}$' + elif b[1] >= 25: + inter_exp = inter_exp + '-' + r'$\bf{high}$' + inter_exp += '\n' + for_wizard += inter_exp + explanation += inter_exp + + bar1 = ax.bar(ind1 - 0.2, y1, 0.4, alpha=1., label='All') + bar2 = ax.bar(ind2 + 0.2, y2, 0.4, alpha=1., label=f'without\n{for_wizard}') + ax.set_ylabel(f'{g_agg} {agg_title}') + ax.set_xlabel(f'{g_att}') + ax.set_xticks(ind1) + ax.set_xticklabels(tuple([str(i) for i in x1]), rotation=45) + ax.legend(loc='best') + ax.set_title(explanation) + bar1[x1.index(target)].set_edgecolor('tab:green') + bar1[x1.index(target)].set_linewidth(2) + bar2[x2.index(target)].set_edgecolor('tab:green') + bar2[x2.index(target)].set_linewidth(2) + ax.get_xticklabels()[x1.index(target)].set_color('tab:green') + + plt.show() + + + def explain(self, df_agg: DataFrame, df_in: DataFrame, g_att: str, g_agg: str, agg_method: str, target: str, + dir: int, control=None, hold_out: List = [], k: int = 1) -> str | None: + """ + Explain the outlier in the given DataFrame. + + This function identifies and explains outliers in the given DataFrame by calculating the influence of + various attributes on the target attribute. It iterates over the attributes, applies filters, and + computes the influence score for each attribute. The most influential attributes are then used to + generate an explanation for the outlier. + + :param df_agg: DataFrame containing the aggregated data. + :param df_in: DataFrame containing the input data. + :param g_att: The grouping attribute. + :param g_agg: The aggregation attribute. + :param agg_method: The aggregation method to be used. + :param target: The target attribute for which the influence is being calculated. + :param dir: Direction factor, either 1 or -1, indicating the direction of the outlier. 1 for high, -1 for low. + :param control: List of control values for the grouping attribute. + :param hold_out: List of attributes to be held out from the analysis. + :param k: Number of top attributes to consider for the explanation. + + :return: None. Will generate a plot with the explanation for the outlier. + """ + # Get the attributes from the input DataFrame and remove the hold-out attributes. + attrs = df_in.columns + attrs = [a for a in attrs if a not in hold_out + [g_att, g_agg]] + + agg_title = agg_method + if agg_method == 'count': + df_agg = df_agg / df_agg.sum() + + predicates = [] + + # If no control values are provided, use all values for the grouping attribute. + if control == None: + control = list(df_agg.index) + df_in_consider = df_in + df_agg_consider = df_agg # [control] + + # Iterate over the attributes, calculate the influence score, and generate predicates. + with ThreadPoolExecutor() as executor: + futures = [ + executor.submit(self.compute_predicates_per_attribute, attr, df_in, g_att, g_agg, agg_method, target, + dir, df_in_consider, df_agg_consider) for attr in attrs + ] + for future in as_completed(futures): + preds = future.result() + predicates += preds + + + # Sort the predicates by influence score in descending order. + predicates.sort(key=lambda x: -x[2]) + + # Merge the predicates to find the most influential attributes. + final_pred, final_inf, final_df = self.merge_preds(df_agg_consider, df_in, df_in_consider, predicates, g_att, + g_agg, agg_method, target, dir) + + # If the final DataFrame is empty, return an error message. Otherwise, generate the explanation plot. + if final_df is None: + return "There was no explanation." + + # Create a new DataFrame with the aggregated values and the control values. + new_df_agg = df_agg.copy() + new_df_agg[control] = final_df[control] + new_df_agg[target] = final_df[target] + final_pred_by_attr = {} + + # Group the final predicates by attribute. + for a, i, rank in final_pred: + if a not in final_pred_by_attr.keys(): + final_pred_by_attr[a] = [] + final_pred_by_attr[a].append((i, rank)) + + # Create a plot to display the explanation for the outlier. + self.draw_bar_plot(df_agg, final_df, g_att, g_agg, final_pred_by_attr, target, agg_title) + return None From 1f68d59a17020e234b238da4cc90803fe52c940b Mon Sep 17 00:00:00 2001 From: Yuval Uner <80361935+YuvalUner@users.noreply.github.com> Date: Mon, 10 Feb 2025 16:50:39 +0200 Subject: [PATCH 2/4] Create LICENSE --- LICENSE | 201 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. From ee448211a5abe610ce368013d06e58af668d2b02 Mon Sep 17 00:00:00 2001 From: Yuval Uner Date: Mon, 10 Feb 2025 16:55:05 +0200 Subject: [PATCH 3/4] Added empty __init__.py for src dir --- src/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/__init__.py diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 From 8c6c0068f85a0f39e8ee3321f7e255a8f0aa5658 Mon Sep 17 00:00:00 2001 From: Yuval Uner Date: Mon, 10 Feb 2025 17:00:07 +0200 Subject: [PATCH 4/4] Added pyproject.toml and v1.0.0 distribution files. --- dist/external_explainers-1.0.0-py3-none-any.whl | Bin 0 -> 12524 bytes dist/external_explainers-1.0.0.tar.gz | Bin 0 -> 12264 bytes pyproject.toml | 3 +++ 3 files changed, 3 insertions(+) create mode 100644 dist/external_explainers-1.0.0-py3-none-any.whl create mode 100644 dist/external_explainers-1.0.0.tar.gz create mode 100644 pyproject.toml diff --git a/dist/external_explainers-1.0.0-py3-none-any.whl b/dist/external_explainers-1.0.0-py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..2b0aba144b272310800e6aeef65da876149efa10 GIT binary patch literal 12524 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 CX=4EZ literal 0 HcmV?d00001 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8cf3256 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" \ No newline at end of file