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. 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/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 0000000..2b0aba1 Binary files /dev/null and b/dist/external_explainers-1.0.0-py3-none-any.whl differ diff --git a/dist/external_explainers-1.0.0.tar.gz b/dist/external_explainers-1.0.0.tar.gz new file mode 100644 index 0000000..8139646 Binary files /dev/null and b/dist/external_explainers-1.0.0.tar.gz differ 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": "iVBORw0KGgoAAAANSUhEUgAAAhMAAAH/CAYAAADtzV9AAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjkuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8hTgPZAAAACXBIWXMAAA9hAAAPYQGoP6dpAABmTklEQVR4nO3dd1gUV9sG8HuXsvSuIIqIWEABC0Yl2BuWaIzdaBSj0RgboibhfWM30RiNPcYWNYkl0SQaNbao2Huv2FBUBFQ6St3z/cHHvK6AsszCgty/6+LSPdOe3VmWe2fOmVEIIQSIiIiICkmp7wKIiIiodGOYICIiIlkYJoiIiEgWhgkiIiKShWGCiIiIZGGYICIiIlkYJoiIiEgWhgkiIiKShWGCiIiIZCn1YSI0NBQKhQKbN29+47yBgYGoUqVK0RelY1WqVEFgYKD0OOc5h4aGFvm2i3NbZcGaNWugUChw7969It1OixYt0KJFC4226Oho9OjRA/b29lAoFJg/f36Z27+BgYGwsLDQdxl6VRT7PK91ltbP29Lk1b8Nb5r3vffeK7JaSmSYUCgUBfp5mz4Ajx07hilTpiA+Pl7fpZQaz58/x5QpU96q90FRGjt2LHbv3o2QkBD88ssvaN++vb5L0vDNN99gy5Yt+i6DqNS6du0apkyZUuRfVvJiWOxbLIBffvlF4/HPP/+MvXv35mr39PTE9evXC7zeFStWQK1W66RGXTt27BimTp2KwMBA2NjYvHbeZs2a4cWLFzA2Ni7yuopzW9p6/vw5pk6dCgC5voWXVB999BH69OkDlUpVpNvZs2dPrrb9+/fj/fffx/jx46W2GjVqlJj9+80336BHjx7o2rWrvkshHSjJn7dvi7CwMCiV/zsmcO3aNUydOhUtWrQo9qNCJTJM9O/fX+PxiRMnsHfv3lztALQKE0ZGRrJrKwmUSiVMTEx0tr6UlBSYm5sXy7bKOgMDAxgYGBT5dvIKBzExMbmCKvcvFZWS9HkrhEBqaipMTU31XYpOFfWXEm2UyNMchaFWq/H111+jUqVKMDExQevWrXH79m2NefI6h7dx40b4+vrC0tISVlZW8Pb2xoIFC964vZSUFIwbNw4uLi5QqVSoWbMm5syZg5dvwnrv3j0oFAqsWbMm1/IKhQJTpkwBAEyZMgUTJkwAALi5uUmncfI7VJXfOc+TJ0+iffv2sLa2hpmZGZo3b46jR49qzDNlyhQoFApcu3YNH374IWxtbdGkSZN8n2de27p16xa6d+8OJycnmJiYoFKlSujTpw8SEhLyf8GQffTAy8sL165dQ8uWLWFmZoaKFSti9uzZueaNiYnB4MGD4ejoCBMTE9SpUwdr166Vpt+7dw/lypUDAEydOlV6zXJe07zExsZi/Pjx8Pb2hoWFBaysrNChQwdcvHgx17yLFi1C7dq1YWZmBltbWzRo0ADr169/7fMryHJ59ZlQq9WYMmUKnJ2dYWZmhpYtW+LatWu5zofmLHv06FEEBwejXLlyMDc3xwcffIAnT55o1PFyn4mc5YQQWLJkifRaAa9/L3Xs2BG2trYwNzeHj4+Pxu/FpUuXEBgYiKpVq8LExAROTk74+OOP8ezZM4315Lzfbt++LR11s7a2xqBBg/D8+XNpPoVCgZSUFKxdu1aqL79zwUIIODg4IDg4WOM1tLGxgYGBgcapwm+//RaGhoZITk7WWMejR4/QtWtXWFhYoFy5chg/fjyysrI05lGr1Zg/fz5q164NExMTODo6YtiwYYiLi9OYL+dc9JEjR9CwYUOYmJigatWq+Pnnn/Os/1UF2c7kyZOhVCqxb98+jWWHDh0KY2Njjffwo0ePMHjwYDg7O0OlUsHNzQ3Dhw9Henp6vjXkd+49r743Dx8+RNeuXWFubo7y5ctj7NixSEtLy7Xsq5+3OZ+Hc+bMwfLly+Hu7g6VSoV33nkHp0+fzrX8pk2bUKtWLZiYmMDLywt//fVXgfth5OyT3bt3o0GDBjA1NcWyZcsAAHfv3kXPnj1hZ2cHMzMzNG7cGDt27JCWlfP+ioqKwqBBg1CpUiWoVCpUqFAB77///mtPO/z9999QKBS4dOmS1PbHH39AoVCgW7duGvN6enqid+/eGs8zZ7+tWbMGPXv2BAC0bNky3+4ABXmf3rlzB3fu3Mm35ryUyCMThTFr1iwolUqMHz8eCQkJmD17Nvr164eTJ0/mu8zevXvRt29ftG7dGt9++y2A7CMdR48exZgxY/JdTgiBLl264MCBAxg8eDDq1q2L3bt3Y8KECXj06BHmzZunVe3dunXDzZs3sWHDBsybNw8ODg4AIP2xLIj9+/ejQ4cO8PX1lT54Vq9ejVatWuHw4cNo2LChxvw9e/ZE9erV8c0330Cbu9Cnp6cjICAAaWlpGDVqFJycnPDo0SNs374d8fHxsLa2fu3ycXFxaN++Pbp164ZevXph8+bN+OKLL+Dt7Y0OHToAAF68eIEWLVrg9u3bGDlyJNzc3LBp0yYEBgYiPj4eY8aMQbly5bB06VIMHz4cH3zwgfRL5+Pjk++27969iy1btqBnz55wc3NDdHQ0li1bhubNm+PatWtwdnYGkH14dvTo0ejRowfGjBmD1NRUXLp0CSdPnsSHH36Y7/oLu1xISAhmz56Nzp07IyAgABcvXkRAQABSU1PznH/UqFGwtbXF5MmTce/ePcyfPx8jR47Eb7/9luf8zZo1wy+//IKPPvoIbdu2xYABA/KtBcj+vXjvvfdQoUIFjBkzBk5OTrh+/Tq2b98u/V7s3bsXd+/exaBBg+Dk5ISrV69i+fLluHr1Kk6cOCGFlRy9evWCm5sbZs6ciXPnzmHlypUoX7689Hv3yy+/YMiQIWjYsCGGDh0KAHB3d8+zPoVCAX9/fxw6dEhqu3TpEhISEqBUKnH06FF06tQJAHD48GHUq1dPo9NlVlYWAgIC0KhRI8yZMwf//vsv5s6dC3d3dwwfPlyab9iwYVizZg0GDRqE0aNHIzw8HIsXL8b58+dx9OhRjW/et2/fRo8ePTB48GAMHDgQP/30EwIDA+Hr64vatWu/9vUuyHa++uorbNu2DYMHD8bly5dhaWmJ3bt3Y8WKFZg+fTrq1KkDAIiMjETDhg0RHx+PoUOHwsPDA48ePcLmzZvx/Plz2aezXrx4gdatWyMiIgKjR4+Gs7MzfvnlF+zfv7/A61i/fj2SkpIwbNgwKBQKzJ49G926dcPdu3el13THjh3o3bs3vL29MXPmTMTFxWHw4MGoWLFigbcTFhaGvn37YtiwYfjkk09Qs2ZNREdH491338Xz588xevRo2NvbY+3atejSpQs2b96MDz74QNb7q3v37rh69SpGjRqFKlWqICYmBnv37kVERES+IahJkyZQKBQ4dOiQ9Pl1+PBhKJVKHDlyRJrvyZMnuHHjBkaOHJnnepo1a4bRo0dj4cKF+M9//gNPT08AkP4FCv4+bd26NQBo1/dClAIjRowQ+ZV64MABAUB4enqKtLQ0qX3BggUCgLh8+bLUNnDgQOHq6io9HjNmjLCyshKZmZla1bNlyxYBQMyYMUOjvUePHkKhUIjbt28LIYQIDw8XAMTq1atzrQOAmDx5svT4u+++EwBEeHh4rnldXV3FwIEDcz3nAwcOCCGEUKvVonr16iIgIECo1WppvufPnws3NzfRtm1bqW3y5MkCgOjbt2+Bnuur2zp//rwAIDZt2lSg5V/WvHlzAUD8/PPPUltaWppwcnIS3bt3l9rmz58vAIhff/1VaktPTxd+fn7CwsJCJCYmCiGEePLkSa7X8XVSU1NFVlaWRlt4eLhQqVRi2rRpUtv7778vateurfXzK8hyq1ev1tjPUVFRwtDQUHTt2lVjvilTpggAGvs9Z9k2bdpo7OexY8cKAwMDER8fL7U1b95cNG/eXGOdAMSIESM02l7dv5mZmcLNzU24urqKuLg4jXlffW+9asOGDQKAOHTokNSW8377+OOPNeb94IMPhL29vUabubm5xvN9ne+++04YGBhI74WFCxcKV1dX0bBhQ/HFF18IIYTIysoSNjY2YuzYsdJyAwcOFAA09rcQQtSrV0/4+vpKjw8fPiwAiHXr1mnMt2vXrlztrq6uuZ53TEyMUKlUYty4ca99Htps5/Lly8LY2FgMGTJExMXFiYoVK4oGDRqIjIwMaZ4BAwYIpVIpTp8+nWtbOfvv1X2e8xzyeu1ffR/l/G7+/vvvUltKSoqoVq1arnW++nmb83lob28vYmNjpfatW7cKAGLbtm1Sm7e3t6hUqZJISkqS2kJDQwUAjXXmJ2ef7Nq1S6M9KChIABCHDx+W2pKSkoSbm5uoUqWK9PlQmPdXXFycACC+++67N9b3qtq1a4tevXpJj+vXry969uwpAIjr168LIYT4888/BQBx8eJFjef58n7btGlTrv3w6mtSkPepq6trgV7nl701pzkGDRqkkbqbNm0KIPvbaH5sbGyQkpKCvXv3arWtf/75BwYGBhg9erRG+7hx4yCEwM6dO7Van1wXLlzArVu38OGHH+LZs2d4+vQpnj59ipSUFLRu3RqHDh3K1RHq008/LdS2co487N69W+MwdUFZWFho9H0xNjZGw4YNNfbTP//8AycnJ/Tt21dqMzIywujRo5GcnIyDBw8WqnaVSiV1VsrKysKzZ89gYWGBmjVr4ty5c9J8NjY2ePjwYZ6HXl+nMMvt27cPmZmZ+OyzzzTaR40ale8yQ4cO1fjm37RpU2RlZeH+/fta1ZuX8+fPIzw8HEFBQbn6V7y8zZfPPaempuLp06do3LgxAGi8ljlefb81bdoUz549Q2JiYqHqzHnOx44dA5D9Ta5p06Zo2rQpDh8+DAC4cuUK4uPjpc+CN9Xz8ntw06ZNsLa2Rtu2baXfp6dPn8LX1xcWFhY4cOCAxvK1atXS2E65cuVQs2bN137+aLsdLy8vTJ06FStXrkRAQACePn2KtWvXwtAw+wCzWq3Gli1b0LlzZzRo0CDXtl49WlQY//zzDypUqIAePXpIbWZmZtLRpILo3bs3bG1tpcevflZHRkbi8uXLGDBggMYRpebNm8Pb27vA23Fzc0NAQECu+hs2bKhxatfCwgJDhw7FvXv3cO3aNakmbd9fpqamMDY2RmhoaK5TYW/y8nqTkpJw8eJFDB06FA4ODlL74cOHYWNjAy8vL63W/bKCvk/v3bun9YiQtyZMVK5cWeNxzpv1dTv1s88+Q40aNdChQwdUqlQJH3/8MXbt2vXGbd2/fx/Ozs6wtLTUaM85nKSLD3Vt3Lp1CwAwcOBAlCtXTuNn5cqVSEtLy9Wfwc3NrVDbcnNzQ3BwMFauXAkHBwcEBARgyZIlb+wvkaNSpUq5PtRsbW019tP9+/dRvXp1jV7KgPzXV61WY968eahevTpUKhUcHBxQrlw56RBmji+++AIWFhZo2LAhqlevjhEjRuTqe5KXwiyX81yqVaum0W5nZ6fxgfuywrzXCyrnPOmbPrBiY2MxZswYODo6wtTUFOXKlZPeU3m9F3Rdc/369WFmZqbxQdu0aVM0a9YMZ86cQWpqqjTt1T5BJiYmuU4hvvoevHXrFhISElC+fPlcv1PJycmIiYl57fPLa5150XY7EyZMQJ06dXDq1ClMnjwZtWrVkqY9efIEiYmJsv7YvMn9+/dRrVq1XL/DNWvWLPA63vReyO93Ir+2/OT1GXf//v08a331s6Uw7y+VSoVvv/0WO3fuhKOjI5o1a4bZs2cjKirqjbU2bdoUjx8/xu3bt3Hs2DEoFAr4+flphIzDhw/D398/1+eiNgr7Pi2It6bPRH495MVr+gOUL18eFy5cwO7du7Fz507s3LkTq1evxoABAzQ6+xVWft8EXu3oJVfOUYfvvvsOdevWzXOeVy/UI6dX89y5cxEYGIitW7diz549GD16NGbOnIkTJ06gUqVKr122MPtJV7755htMnDgRH3/8MaZPnw47OzsolUoEBQVpHLnx9PREWFgYtm/fjl27duGPP/7ADz/8gEmTJklDUfNS2OW0pc/XMEevXr1w7NgxTJgwAXXr1oWFhQXUajXat2+f53BAXddsZGSERo0a4dChQ7h9+zaioqLQtGlTODo6IiMjAydPnsThw4fh4eGRKzgUZDSNWq1G+fLlsW7dujynF3Sdb3p+2m7n7t270peHy5cvv3bd2njdZ5WuRx8V1/tXzmdcYd9fQUFB6Ny5M7Zs2YLdu3dj4sSJmDlzJvbv34969erlu72cQHLo0CHcvXsX9evXh7m5OZo2bYqFCxciOTkZ58+fx9dff13o5wQU7Wv/1oSJwjI2Nkbnzp3RuXNnqNVqfPbZZ1i2bBkmTpyYbwp2dXXFv//+i6SkJI2jEzdu3JCmA/9L3K9eiCqvb9ZyDkHmdFSzsrJCmzZtCr0ebXh7e8Pb2xtfffUVjh07Bn9/f/z444+YMWOG7HW7urri0qVLUKvVGin81ddX29ds8+bNaNmyJVatWqXRHh8fL3V6zWFubo7evXujd+/eSE9PR7du3fD1118jJCTktUMptV0u57ncvn1b45vUs2fPdPJtQVs576UrV67k+16Ki4vDvn37MHXqVEyaNElqz/kjV1ja7s+mTZvi22+/xb///gsHBwd4eHhAoVCgdu3aOHz4MA4fPlzoK/65u7vj33//hb+/f5EOJ9RmO2q1GoGBgbCyskJQUJB0XY6czsflypWDlZUVrly5onUdtra2eV4w7/79+6hatar02NXVFVeuXIEQQmN/hYWFab3N/Lz8O/GqvNq0XXdetb762QIU/v3l7u6OcePGYdy4cbh16xbq1q2LuXPn4tdff823rsqVK6Ny5co4fPgw7t69K52KaNasGYKDg7Fp0yZkZWWhWbNmr31+ujiVVVhvzWmOwnh1GJtSqZR60+Y11ClHx44dkZWVhcWLF2u0z5s3DwqFQhqVYGVlBQcHB41ewQDwww8/5FpnznUeCnMFTF9fX7i7u2POnDm5hsAByDVsUI7ExERkZmZqtHl7e0OpVL72NdNGx44dERUVpTE6ITMzE4sWLYKFhQWaN28OIPtcLVDw18zAwCBXAt+0aRMePXqk0fbq+8LY2Bi1atWCEAIZGRn5rr8wy7Vu3RqGhoZYunSpRvur763iUr9+fbi5uWH+/Pm5Xtec1y7n282rr+X8+fNlbdvc3Fyr93/Tpk2RlpaG+fPnSz3ic9p/+eUXREZG5tlfoiB69eqFrKwsTJ8+Pde0zMxMnV2pVpvtfP/99zh27BiWL1+O6dOn491338Xw4cPx9OlTANmfX127dsW2bdtw5syZXOt73bdPd3d3nDhxQmP46Pbt2/HgwQON+Tp27IjIyEiN2xc8f/4cy5cvL/BzfhNnZ2d4eXnh559/1vg8O3jwoOyjMR07dsSpU6dw/PhxqS0lJQXLly9HlSpVNE4bafv+ev78ea4RWO7u7rC0tCzQZ2PTpk2xf/9+nDp1Slpv3bp1YWlpiVmzZsHU1BS+vr6vXYecvyMvK9NDQwtjyJAhiI2NRatWrVCpUiXcv38fixYtQt26dTWG07yqc+fOaNmyJf773//i3r17qFOnDvbs2YOtW7ciKChIY0jbkCFDMGvWLAwZMgQNGjTAoUOHcPPmzVzrzHmT/Pe//0WfPn1gZGSEzp0753sxqZcplUqsXLkSHTp0QO3atTFo0CBUrFgRjx49woEDB2BlZYVt27YV4hXKbf/+/Rg5ciR69uyJGjVqIDMzE7/88gsMDAzQvXt3nWxj6NChWLZsGQIDA3H27FlUqVIFmzdvxtGjRzF//nzpaJCpqSlq1aqF3377DTVq1ICdnR28vLzyPWf83nvvYdq0aRg0aBDeffddXL58GevWrdP45gUA7dq1g5OTE/z9/eHo6Ijr169j8eLF6NSpU65+MnKXc3R0xJgxYzB37lx06dIF7du3x8WLF7Fz5044ODgU+zcNpVKJpUuXonPnzqhbty4GDRqEChUq4MaNG7h69Sp2794NKysr6XxwRkYGKlasiD179iA8PFzWtn19ffHvv//i+++/h7OzM9zc3NCoUaN85/fz84OhoSHCwsI0OgA2a9ZMCmeFDRPNmzfHsGHDMHPmTFy4cAHt2rWDkZERbt26hU2bNmHBggUanRALq6DbuX79OiZOnIjAwEB07twZQPZ1BerWrYvPPvsMv//+O4DsU3l79uxB8+bNMXToUHh6euLx48fYtGkTjhw5ku/VdYcMGYLNmzejffv26NWrF+7cuYNff/011/DcTz75BIsXL8aAAQNw9uxZVKhQAb/88osU7HXlm2++wfvvvw9/f38MGjQIcXFxWLx4Mby8vPL8wlRQX375JTZs2IAOHTpg9OjRsLOzw9q1axEeHo4//vhD40iotu+vmzdvonXr1ujVqxdq1aoFQ0ND/PXXX4iOjkafPn3eWFvTpk2xbt06KBQK6bSHgYEB3n33XezevRstWrR449DeunXrwsDAAN9++y0SEhKgUqnQqlUrlC9fXqvXqUwPDX11qGJewzJfHaq0efNm0a5dO1G+fHlhbGwsKleuLIYNGyYeP378xpqSkpLE2LFjhbOzszAyMhLVq1cX3333ncbwOSGyh9ANHjxYWFtbC0tLS9GrVy8RExOT55DG6dOni4oVKwqlUqkxfPBNQ0NznD9/XnTr1k3Y29sLlUolXF1dRa9evcS+ffukeXKG6j158uSNzzGvbd29e1d8/PHHwt3dXZiYmAg7OzvRsmVL8e+//75xXc2bN89z6OSr+0UIIaKjo8WgQYOEg4ODMDY2Ft7e3nkOsT127Jjw9fUVxsbGbxwmmpqaKsaNGycqVKggTE1Nhb+/vzh+/Hiu4W/Lli0TzZo1k15Hd3d3MWHCBJGQkPDa51eQ5V4dGipE9nDMiRMnCicnJ2FqaipatWolrl+/Luzt7cWnn36aa9lXh/3l9X4o7NDQHEeOHBFt27YVlpaWwtzcXPj4+IhFixZJ0x8+fCg++OADYWNjI6ytrUXPnj1FZGRkrn2Q3/str9fhxo0bolmzZsLU1DTXsNj8vPPOOwKAOHnypEZtAISLi0uu+QcOHCjMzc1ztefU+arly5cLX19fYWpqKiwtLYW3t7f4/PPPRWRkpDSPq6ur6NSpU65l89oH+XnddjIzM8U777wjKlWqpDH8V4j/DYH/7bffpLb79++LAQMGiHLlygmVSiWqVq0qRowYIQ2dz2+fz507V1SsWFGoVCrh7+8vzpw5k+dzuH//vujSpYswMzMTDg4OYsyYMdJQ1oIMDc1r6GRev7sbN24UHh4eQqVSCS8vL/H333+L7t27Cw8Pjze+nvntEyGEuHPnjujRo4ewsbERJiYmomHDhmL79u15zqvN++vp06dixIgRwsPDQ5ibmwtra2vRqFEjjWG0r3P16lXpMgcvmzFjhgAgJk6cmOfzfPX3ZMWKFaJq1arCwMBAY59o8z4tzNBQhRDF2GuLiAokPj4etra2mDFjBv773//quxyiEqFu3booV66c1sP5qeiV6T4TRCXBixcvcrXl9D8oLTcwI9KljIyMXH2zQkNDcfHiRf5OlFA8MkGkZ2vWrMGaNWvQsWNHWFhY4MiRI9iwYQPatWuH3bt367s8omJ37949tGnTBv3794ezszNu3LiBH3/8EdbW1rhy5Qrs7e31XSK9okx3wCQqCXx8fGBoaIjZs2cjMTFR6pSpi2G2RKWRra0tfH19sXLlSjx58gTm5ubo1KkTZs2axSBRQvHIBBEREcnCPhNEREQkC8MEERERycIwQURERLIwTBAREZEsDBNElKfAwEAoFAooFAqEhoa+sV3ueomo9GKYICK9u3fvHqZMmYIpU6Zgy5YtxbbdtLQ0fPPNN6hVqxZMTExgb2+Prl274ty5c8VWA9HbgENDiShPgYGBWLt2LQDgwIED0pUHb926hejoaADZd4y1trbWar15LR8aGoqWLVsCAAYOHIg1a9bo5km8RmZmJtq3b499+/blmqZSqbBjxw7phkdE9Hq8aBURaaV69eqoXr263pbXlR9++EEKEl5eXpg6dSrOnz+PGTNmIC0tDYGBgbh9+zZUKpWeKyUq+Xiag6iEevLkCYKDg1G9enWoVCrY2tqiU6dOOHHihDRPaGgolEolFAoFGjVqBLVaDSD7tIGFhQUUCgUqVKiA2NhYjb4Ke/fuxcSJE1GxYkWYmpqiWbNmBT60/7o+D7GxsQgJCUGtWrVgZmYGKysr1K9fH4sXL853+RYtWkhHJQBg7dq10vTAwMA8a0hISMCRI0cK9JOWlpbnOn788Ufp/ytWrEC3bt0wffp0BAQEAAAePnyI7du3F+g1ISrztLrHKBEVi/v374tKlSoJALl+jIyMxNatW6V5R44cKU3LuU14u3btpLa///5bCJF9S+ictpo1a+Zar5WVlQgLC5PW+/L8r95aOq/2iIgIUbly5TxrfvkWx68u37x58zyXwWtuQ55zG+2C/Lx8m/Mcz54903g9MzMzpWlTp06Vpo0ZM6bgO42oDOORCaIS6LPPPsPDhw8BAAMGDMCuXbuwdOlSWFhYICMjAx9//DFSUlIAALNmzYK7uzsA4L///S9mz56NPXv2AMg+CtC5c+dc63/w4AEWLFiALVu2oEGDBgCAxMREhISEyKo5IiICAFC5cmUsX74cu3btwuzZs+Hi4pLvcosWLcLChQulxx06dMDhw4dx+PDhIrv9+r1796T/29vbw8DAQHpcvnx56f/h4eFFsn2itw37TBCVMLGxsfjnn38AAE5OTvjkk08AZJ/Xb9u2Lf766y88e/YMu3btQvfu3WFubo6ffvoJLVq0QGJiIr744gsAQKVKlaRbmb9q7NixGD16NACgVq1aqFGjBgDgn3/+QUZGBoyMjApds4GBAXbt2gVPT08AkE4b5Mfb2xvPnj2THpcvXx5NmjR57TItWrSAkNF3PCeIAYCxsbHGtJcfvzwfEeWPYYKohLl9+7b0hzIqKgpNmzbNc77r169L/2/WrBlGjBih0Tdh+fLl+Y60aNSokfT/6tWrw9bWFnFxcUhNTUVkZCRcXV21rjmnv0bVqlWlIFFUEhIScPny5QLN+8477+TqRGlubi79/9U+Fenp6XnOR0T5Y5ggKqVe/dYcFham8fjKlSvo0KFDgdalUCh0VldxOH/+vEanzdcJDw9HlSpVNNpefvzs2TNkZmbC0DD74zAqKkqa5ubmJrtWorKAfSaISphq1apJf9zd3d2RmZkJIYTGT3p6OqZNmyYts2zZMuzduxcApPP/kyZNwo0bN/LcxqlTp6T/3759G7GxsQAAExMTODs7F6pmpTL74+Tu3bv5bjc/OcsCkI5wFCU7Ozvp6ElmZiZOnz4tTTt+/Lj0//yOChGRJoYJohLGzs5OOqJw584ddOnSBX/++Sf27t2LlStXYsSIEahcuTIePXoEALh//z4mTJgAAHB1dcW2bdugUCiQmpqKwMBAZGVl5drGvHnzsHjxYvz999/o16+f1N6hQwet+0u8WnNWVhY6dOiAVatWYc+ePZg3bx4++uij1y5va2sr/f/IkSPYuXMnjhw5gpiYmDznz+kzUZCfV49K5Pj000+l/3/yySf4888/8dVXX0mdVytVqoT33ntPm5eBqOwq/gEkRPQmrxsaipeGPKrVatGqVSupbefOnUIIIYYPHy61ffvtt0IIzSGZPj4+udZnYWEhrl+/LtWg7dDQ19X8uqGhQgiRkZEhnJycci23evXqonqJRUZGhmjdunWe9apUKvHvv/8W2baJ3jY8MkFUAlWuXBnnz5/HhAkT4OHhARMTE1haWsLDwwMDBgzA33//DRcXFyxduhT79+8HAHz44Ydo3749AODbb7+VhmNOmjRJo7MmAMydOxdTpkxBxYoVoVKp0KRJExw4cAAeHh6ya/7888+lmi0sLFC3bl306NHjtcsaGhri77//RpMmTWBpaVnoGrRhaGiIHTt24Ouvv4aHhwdUKhXs7OzQpUsXHDt2jJfSJtIC781BVEbkd68NIiK5eGSCiIiIZGGYICIiIlkYJoiIiEgW9pkgIiIiWXhkgoiIiGRhmCAiIiJZGCaIiIhIFoYJIiIikoVhgoiIiGRhmCAiIiJZGCaIiIhIFoYJIiIikoVhgoiIiGRhmCAiIiJZGCaIiIhIFoYJIiIikoVhgoiIiGRhmCAiIiJZGCaIiIhIFoYJIiIikoVhgoiIiGRhmCAiIiJZGCaIiIhIFoYJIiIikoVhgoiIiGRhmCAiIiJZGCaIiIhIFoYJIiIikoVhgoiIiGQx1HcBRU2tViMyMhKWlpZQKBT6LoeIiKjICCGQlJQEZ2dnKJXFd7zgrQ8TkZGRcHFx0XcZRERExebBgweoVKlSsW3vrQ8TlpaWALJfWCsrKz1XQ0REVHQSExPh4uIi/e0rLm99mMg5tWFlZcUwQUREZUJxn9ZnB0wiIiKShWGCiIiIZGGYICIiIlne+j4TRESllRACmZmZyMrK0ncpVEIYGBjA0NCwxF3qgGGCiKgESk9Px+PHj/H8+XN9l0IljJmZGSpUqABjY2N9lyJhmCAiKmHUajXCw8NhYGAAZ2dnGBsbl7hvolT8hBBIT0/HkydPEB4ejurVqxfrhaleh2GCiKiESU9Ph1qthouLC8zMzPRdDpUgpqamMDIywv3795Geng4TExN9lwSAHTCJiEqskvKtk0qWkvi+KHkVERERUanCMEFERESysM8EEVEpUuXLHcW6vXuzOul0faGhoWjZsiXi4uJgY2ODNWvWICgoCPHx8TrdDhUvHpkgIiKdO378OAwMDNCpk27DCJVMDBNERKRzq1atwqhRo3Do0CFERkbquxwqYgwTRESkU8nJyfjtt98wfPhwdOrUCWvWrNF3SVTE2GeCiKigplgXz3YsXAD/uUDMC8Cw9F2s6vfff4eHhwdq1qyJ/v37IygoCCEhIbzw1luMRyaIiEinVq1ahf79+wMA2rdvj4SEBBw8eFDPVVFRYpggIiKdCQsLw6lTp9C3b18AgKGhIXr37o1Vq1bpuTIqSjzNQUREOrNq1SpkZmbC2dlZahNCQKVSYfHixXqsjIoSwwRRSVBc5+J1YUqCviugEiozMxM///wz5s6di3bt2mlM69q1KzZs2AAPDw89VUdFiWGCiIh0Yvv27YiLi8PgwYNhba0ZkLt3745Vq1bhu+++01N1VJT0GiamTJmCqVOnarTVrFkTN27cAACkpqZi3Lhx2LhxI9LS0hAQEIAffvgBjo6O+iiXiEjv7o12fvNMAOBcr2gLycOqVavQpk2bXEECyA4Ts2fPxqVLl4q9Lip6eu+AWbt2bTx+/Fj6OXLkiDRt7Nix2LZtGzZt2oSDBw8iMjIS3bp102O1RESUn23btmHHjrwv992wYUMIITB69GgIIWBjYwMACAwM5KW03wJ6P81haGgIJyenXO0JCQlYtWoV1q9fj1atWgEAVq9eDU9PT5w4cQKNGzfOc31paWlIS0uTHicmJhZN4URERASgBByZuHXrFpydnVG1alX069cPERERAICzZ88iIyMDbdq0keb18PBA5cqVcfz48XzXN3PmTFhbW0s/Li4uRf4ciIiIyjK9holGjRphzZo12LVrF5YuXYrw8HA0bdoUSUlJiIqKgrGxsXQoLIejoyOioqLyXWdISAgSEhKknwcPHhTxsyAiIirb9Hqao0OHDtL/fXx80KhRI7i6uuL333+HqalpodapUqmgUql0VSIRERG9gd5Pc7zMxsYGNWrUwO3bt+Hk5IT09PRcHXOio6Pz7GNBRERE+lGiwkRycjLu3LmDChUqwNfXF0ZGRti3b580PSwsDBEREfDz89NjlURERPQyvZ7mGD9+PDp37gxXV1dERkZi8uTJMDAwQN++fWFtbY3BgwcjODgYdnZ2sLKywqhRo+Dn55fvSA4iIiIqfnoNEw8fPkTfvn3x7NkzlCtXDk2aNMGJEydQrlw5AMC8efOgVCrRvXt3jYtWERER5edO/B1kqjP1XUaBGSoN4W7jru8yZNFrmNi4ceNrp5uYmGDJkiVYsmRJMVVERESlXaY6s1SFibdBieozQUREZcOaNWtyDf3Pi0KhwJYtWwq9HUMhSuzP20TvV8AkIqKyp3fv3ujYsaP0eMqUKdiyZQsuXLigs20YCoGaGRkFmldRsT7+WjUXXdu31Nn23yTMyAiZCkWxba8oMUwQEVGxMzU1LfT1hKjk4WkOIiLSie3bt8PGxgZZWVkAgAsXLkChUODLL7+U5hkyZAj69++vcZpjzZo1mDp1Ki5evAiFQgGFQoE1a9ZIyzx9+hQffPABzMzMUL16dfz9998a2z148CAaNmwIlUqFChUqYO7UucjM/F+fiSqNOmH+inUay9Rt2wdT5v4oTQeADwaPg6JifekxFRzDBBER6UTO7RDOnz8PIPuPvIODA0JDQ6V5Dh48iBYtWmgs17t3b4wbN07jLtK9e/eWpk+dOhW9evXCpUuX0LFjR/Tr1w+xsbEAgEePHqFjx4545513cPHiRSxduhSb123Gsu+XFbju0//8CgBY/f0UPD6/R3pMBccwQUREOmFtbY26detK4SE0NBRjx47F+fPnkZycjEePHuH27dto3ry5xnKmpqawsLCQ7iLt5OSkcQokMDAQffv2RbVq1fDNN98gOTkZp06dAgD88MMPcHFxweLFi+Hh4YGuXbti1BejsPaHtVCr1QWqu5y9LQDAxtoSTuUdpMdUcAwTRESkM82bN0doaCiEEDh8+DC6desGT09PHDlyBAcPHoSzszOqV6+u1Tp9fHyk/5ubm8PKygoxMTEAgOvXr8PPzw+Klzoy1m9UH89TniMqMlo3T4reiB0wiYhIZ1q0aIGffvoJFy9ehJGRETw8PNCiRQuEhoYiLi4u11GJgjAyMtJ4rFAoCnzUAQCUSiXEK0MxMzJ5HQpd4pEJIiLSmZx+E/PmzZOCQ06YCA0NzdVfIoexsbHUcVMbnp6eOH78uEZYOHfyHMwtzOHk7Agg+zTG45in0vTEpGSER0RqrMfIyBBZWQUPKKSJYYKIiHTG1tYWPj4+WLdunRQcmjVrhnPnzuHmzZv5HpmoUqUKwsPDceHCBTx9+hRpaWkF2t5nn32GBw8eYNSoUbhx4wa2bt2KRd8uwoDhA6BUZv+Ja+X/Dn754x8cPnkOl6/fwsCgyTAw0PzzV6WSM/YdOYWomKeIi08s/AtQRjFMEBGRTjVv3hxZWVlSmLCzs0OtWrXg5OSEmjVr5rlM9+7d0b59e7Rs2RLlypXDhg0bCrStihUr4p9//sGpU6dQp04dfPrpp+jRrweGBQ+T5gkZOQjNG9fHewOD0GnAGHQNaAF310oa65k7aSz2HjoBl3c6ol5A38I98TJMIV49kfSWSUxMhLW1NRISEmBlZaXvcojyNsVa3xUU3JQEfVegP8W0n1ItXBDuPxduFcvBxLCQV0h0rqfbokqRsNgwZKoztboCpj7kXAHTUGmImnZ5h6y8pKamIjw8HG5ubjAxMdGYpq+/eTwyQURERLIwTBAREZEsDBNEREQkC8MEERERycIwQURERLIwTBAREZEsDBNERFSqValSBQqFAlOmTJHa2tVvB4/y3tJtxgu0nkadoKhYX6tlKBvDBBERvXU8vD1Qx9cHlSo4FniZel4eaFTPS1omMGgyFBXro0WPT4qqTPzz1z/o1rIbTE1NYWdnhx49euDOnTtFtr2iwht9ERGVJstbFO/2SulFyhauXaj1Rav+WjW3CCvKbfO6P/HV2MkAADc3Nzx79gx//PEHDh8+jIsXL8LJyalY65GDRyaIiEin1Go1FixYAC8vL5iYmMDW1hY9e/ZEeHg4AGDr1q1QKBRQKpUIDQ0FAGzfvl1q27dvH4D/nb748ssvMXLkSNjZ2cHa2hqfffbZG+/d8eppDiEEfljzO+q16wtTdz9Y1miChp0+woUrYdIyL5/mqNKoE9Zu2gYAOHj8LBQV60NRsT5Cj53RyWuUnp6BuTPmZ9fauR3u3r2L69evw9LSEjExMfjmm290sp3iwjBBREQ6NXLkSAQFBeHq1auoVq0aDAwMsHnzZrz77ruIiYnB+++/jyFDhkAIgU8++QSPHz/GsGHZ99IICgpC69atNdY3f/58bNy4ETY2NkhMTMTSpUsREhKiVU2jJ87GiP/OwoWrYTA3NYVrpQq4eO0m7j2MzHP+el4ecLCzAQBYWpijUT0vNKrnBStL81zzTpn7oxQ28vu590BzO6cvXkXcszgA2WECAJydndG4cWMAwK5du7R6fvrGMEFERDoTHh6OH3/MPhqwdu1aXLlyBffu3UOlSpUQFRWFRYsWAQDmzZsHd3d33L59G/Xq1UNkZCS8vb0xc+bMXOusXLkywsPDcffuXfTtm30TriVLliAhoWCnYO49iMSSNb8DAD7o0BKR53bjyv5NeHhmFxr41Mpzmb9WzUWn1k0BAPW9PXBi+884sf1n1Pf2zDVvpQqOUtjI70dlbKSxzIPIaOn/9g720v8dHbP7a0RERBTouZUU7DNBREQ6c+bMGeTcP3LgwIEYOHCgxvQTJ04AACwsLPDrr7/C398f0dHRMDIywrp166BSqXKt87333oOlpSUAoE+fPtiwYQPS09Nx8+ZNvPPOO2+s6fSFq1JN44Z9BOP//8Nezt628E/0JUM+/ABDPvxAJ+sqrffeZJggIqIiUbdu3VzhwNXVVfp/REQE1Go1ACAjIwP379+Ht7d3sdaoCyvX/4WV6/967Tx/rZqLCo7lpMcuzv8bZfLs6TPp/zExMQCyj8aUJgwTRESkM76+vlAoFBBCIDAwEGPGjAGQ/Y37yJEjsLbOvo37o0eP8OmnnwLIDh0XLlzAkCFDcPnyZZQrV05jnTt27MC0adNgYWGB33/PPl1hbGyMGjVqFKimd+rWlmqav2I93qlTG8bGRngWG48XqWmo5Jz38FEz0+zbe6c8f/Ha9T98HI2T56+8dp60dM1RJe/UqQ0bOxvEx8Zjz7Y9GPfJOERGRkpHbtq3b1+g51ZSsM8EERHpTNWqVfHJJ9nXZQgKCkLVqlXh4+MDGxsbNGvWDOfOnZOCRlxcHN59910cP34cPj4+iI6OxtChQ3Ot89GjR3Bzc4O7uzvWrVsHABg+fLgUTN6kioszRgT2AgBs3vEvKvoGwLt1L1Rs0B5nLl3LdzmPalUAAGcuXoN3615o/N4AvHiRmmu+KeM+hXh07rU/VVycNZYxNjbC2P+MBgDs2bYHVatWhaenJ5KSkuDg4IAvv/yyQM+tpGCYICIinVq6dCnmzZsHb29vREZG4v79+6hSpQqCg4PRokULLFy4EP/++y9MTU2xevVqmJiYYO3atTAyMsKWLVvw008/aaxvzJgx6N+/P+Li4mBpaYlhw4Zh1qxZWtW0cPrnWPL1l6hbuyaSn79AeMQj+HhWR5VKzvku83Gf99G9Y2tYW1ngyo3bOHn+CrL+/7SMLvQe0BOzls6Cp7cnIiMjoVAo0K1bNxw7dgzOzvnXVRIpRGnt7VFAiYmJsLa2RkJCAqysrPRdDlHephTsG1aJUEovYqQTxbSfUi1cEO4/F24Vy8HEUKE5sQxdtKpKlSq4f/8+Jk+erHGp7DcJiw1DpjpT64tWFbcwIyNkKhQwVBqipl3NAi+XmpqK8PBwuLm5wcTERGOavv7msc/E2660/JEqy3+giLQxNLRg8znXK9IyiF7G0xxEREQkC49MEBFRiXTv3j19l0AFxCMTREREJAvDBBEREcnCMEFERESyMEwQERGRLAwTREREJAtHcxRClS936LuEArtn8uZ53malZV+V9f1ERKUbj0wQERGRLAwTREREJAvDBBEREcnCMEFERESyMEwQERGRLAwTREREJAvDBBEREcnCMEFERESyMEwQERGRLAwTREREJAvDBBEREcnCMEFERESyMEwQERGRLAwTREREJAvDBBFRSSPUAATUQt+FUEmkVqv1XUIuhvougIiINBk/j4byRSwi46xQztoExkpAodByJampRVJbaaBOV0Mt1FALgdTMkpvI1FBDrVBArVAjtQD7SwiB9PR0PHnyBEqlEsbGxsVQZcGUmDAxa9YshISEYMyYMZg/fz4AIDU1FePGjcPGjRuRlpaGgIAA/PDDD3B0dNRvsURERUgpMuF2aiIee3yMyHJ1AWUhPqpTwnVeV2kRnRKNLJEFAwDIzNR3OfmKNjREFgADhQEQV/DlzMzMULlyZSiVJefkQokIE6dPn8ayZcvg4+Oj0T527Fjs2LEDmzZtgrW1NUaOHIlu3brh6NGjeqqUiKh4GKc+ReUL3yHT2ApZRpbaH5oYeaZoCisFpu6aitgXsbDLysSaqBh9l5OvqU7lEWtgCDtTO6xpv6ZAyxgYGMDQ0BAKrQ9VFS29h4nk5GT069cPK1aswIwZM6T2hIQErFq1CuvXr0erVq0AAKtXr4anpydOnDiBxo0b66tkIqJioYCAUXoCjNITtF/YxET3BZUSTzKeICY9BlmZmTBJjtR3Ofl6kpaFGENDZBlmwaSU7y+9HyMZMWIEOnXqhDZt2mi0nz17FhkZGRrtHh4eqFy5Mo4fP57v+tLS0pCYmKjxQ0REREVHr0cmNm7ciHPnzuH06dO5pkVFRcHY2Bg2NjYa7Y6OjoiKisp3nTNnzsTUqVN1XSoRERHlQ29HJh48eIAxY8Zg3bp1Oj28ExISgoSEBOnnwYMHOls3ERER5aa3MHH27FnExMSgfv36MDQ0hKGhIQ4ePIiFCxfC0NAQjo6OSE9PR3x8vMZy0dHRcHJyyne9KpUKVlZWGj9ERERUdPR2mqN169a4fPmyRtugQYPg4eGBL774Ai4uLjAyMsK+ffvQvXt3AEBYWBgiIiLg5+enj5KJiIgoD3oLE5aWlvDy8tJoMzc3h729vdQ+ePBgBAcHw87ODlZWVhg1ahT8/Pw4koOIiKgE0fvQ0NeZN28elEolunfvrnHRKiIiIio5SlSYCA0N1XhsYmKCJUuWYMmSJfopiIiIiN5I79eZICIiotKNYYKIiIhkYZggIiIiWRgmiIiISBaGCSIiIpKFYYKIiIhkYZggIiIiWRgmiIiISBaGCSIiIpKFYYKIiIhkYZggIiIiWRgmiIiISBaGCSIiIpKFYYKIiIhkYZggIiIiWRgmiIiISBaGCSIiIpKFYYKIiIhkYZggIiIiWRgmiIiISBaGCSIiIpKFYYKIiIhkYZggIiIiWRgmiIiISBbDwix069YtHDhwADExMVCr1RrTJk2apJPCiIiIqHTQOkysWLECw4cPh4ODA5ycnKBQKKRpCoWCYYKIiKiM0TpMzJgxA19//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/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 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/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 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