From 0a53650b2f761b62dd6c628874eaef85eb08195d Mon Sep 17 00:00:00 2001 From: kwinkunks Date: Sat, 1 Jul 2023 21:08:23 +0200 Subject: [PATCH 01/16] Correct URLs etc --- docs/index.rst | 4 ++-- docs/post_process_html.py | 2 +- pyproject.toml | 2 +- src/redflag/distributions.py | 2 +- src/redflag/imbalance.py | 2 +- src/redflag/importance.py | 2 +- src/redflag/independence.py | 2 +- src/redflag/outliers.py | 2 +- src/redflag/sklearn.py | 2 +- src/redflag/target.py | 2 +- src/redflag/utils.py | 2 +- 11 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 5669fc3..977ac11 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -82,5 +82,5 @@ Indices and tables PyPI releases Code in GitHub Issue tracker - Community guidelines - Scienxlab + Community guidelines + Scienxlab diff --git a/docs/post_process_html.py b/docs/post_process_html.py index 7f1d454..a88d19f 100644 --- a/docs/post_process_html.py +++ b/docs/post_process_html.py @@ -26,7 +26,7 @@ def add_analytics(html): """ s = r'' pattern = re.compile(s) - new_s = '' + new_s = '' html = pattern.sub(new_s, html) return html diff --git a/pyproject.toml b/pyproject.toml index a1263b8..c4d3904 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dev = [ ] [project.urls] -"documentation" = "https://scienxlab.github.io/redflag" +"documentation" = "https://scienxlab.org/redflag" "repository" = "https://github.com/scienxlab/redflag" [tool.setuptools_scm] diff --git a/src/redflag/distributions.py b/src/redflag/distributions.py index f7eea5d..6e8416d 100644 --- a/src/redflag/distributions.py +++ b/src/redflag/distributions.py @@ -1,7 +1,7 @@ """ Functions related to understanding distributions. -Author: Matt Hall, scienxlab.com +Author: Matt Hall, scienxlab.org Licence: Apache 2.0 Copyright 2022 Redflag contributors diff --git a/src/redflag/imbalance.py b/src/redflag/imbalance.py index 5540ef8..0c1f8e0 100644 --- a/src/redflag/imbalance.py +++ b/src/redflag/imbalance.py @@ -7,7 +7,7 @@ Pattern Recognition Letters 98 (2017) https://doi.org/10.1016/j.patrec.2017.08.002 -Author: Matt Hall, scienxlab.com +Author: Matt Hall, scienxlab.org Licence: Apache 2.0 Copyright 2022 Redflag contributors diff --git a/src/redflag/importance.py b/src/redflag/importance.py index 05a8feb..8e3e5be 100644 --- a/src/redflag/importance.py +++ b/src/redflag/importance.py @@ -1,7 +1,7 @@ """ Feature importance metrics. -Author: Matt Hall, scienxlab.com +Author: Matt Hall, scienxlab.org Licence: Apache 2.0 Copyright 2022 Redflag contributors diff --git a/src/redflag/independence.py b/src/redflag/independence.py index 061d2fd..201c827 100644 --- a/src/redflag/independence.py +++ b/src/redflag/independence.py @@ -1,7 +1,7 @@ """ Functions related to understanding row independence. -Author: Matt Hall, scienxlab.com +Author: Matt Hall, scienxlab.org Licence: Apache 2.0 Copyright 2022 Redflag contributors diff --git a/src/redflag/outliers.py b/src/redflag/outliers.py index c759d1c..d40c30b 100644 --- a/src/redflag/outliers.py +++ b/src/redflag/outliers.py @@ -1,7 +1,7 @@ """ Functions related to understanding features. -Author: Matt Hall, scienxlab.com +Author: Matt Hall, scienxlab.org Licence: Apache 2.0 Copyright 2022 Redflag contributors diff --git a/src/redflag/sklearn.py b/src/redflag/sklearn.py index ed82bda..9ff0438 100644 --- a/src/redflag/sklearn.py +++ b/src/redflag/sklearn.py @@ -1,7 +1,7 @@ """ Scikit-learn components. -Author: Matt Hall, scienxlab.com +Author: Matt Hall, scienxlab.org Licence: Apache 2.0 Copyright 2022 Redflag contributors diff --git a/src/redflag/target.py b/src/redflag/target.py index 1acbf8f..641a74f 100644 --- a/src/redflag/target.py +++ b/src/redflag/target.py @@ -1,7 +1,7 @@ """ Functions related to understanding the target and the type of task. -Author: Matt Hall, scienxlab.com +Author: Matt Hall, scienxlab.org Licence: Apache 2.0 Copyright 2022 Redflag contributors diff --git a/src/redflag/utils.py b/src/redflag/utils.py index 7151a5d..6f73b29 100644 --- a/src/redflag/utils.py +++ b/src/redflag/utils.py @@ -1,7 +1,7 @@ """ Utility functions. -Author: Matt Hall, scienxlab.com +Author: Matt Hall, scienxlab.org Licence: Apache 2.0 Copyright 2022 Redflag contributors From ca73ee70fa6ec000e69aa74a67d203d3b02e67b5 Mon Sep 17 00:00:00 2001 From: kwinkunks Date: Mon, 3 Jul 2023 23:25:48 +0200 Subject: [PATCH 02/16] resolves #32 --- CHANGELOG.md | 7 +- docs/index.rst | 1 + docs/make.bat | 35 --- docs/notebooks/Tutorial.ipynb | 214 +++++++++++------- .../Using_redflag_with_sklearn.ipynb | 156 ++++++++++++- src/redflag/sklearn.py | 60 ++++- tests/test_sklearn.py | 23 +- 7 files changed, 351 insertions(+), 145 deletions(-) delete mode 100644 docs/make.bat diff --git a/CHANGELOG.md b/CHANGELOG.md index f3e29be..e249d22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,11 @@ # Changelog -## 0.1.11, in development +## 0.1.11, summer 2023 -- Coming soon... +- Added custom 'alarm' `Detector`, which can be instantiated with a function and a warning to emit when the function returns True for a 1D array. +- Added `make_detector_pipeline()` which can take sequences of functions and warnings (or a mapping of functions to warnings) and returns a `scikit-learn.pipeline.Pipeline` containing a `Detector` for each function. +- Changed the wording slightly in the existing detectors. +- Added a `Tutorial.ipynb` notebook to the docs. ## 0.1.10, 21 November 2022 diff --git a/docs/index.rst b/docs/index.rst index 977ac11..7703273 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -41,6 +41,7 @@ User guide installation _notebooks/Basic_usage.ipynb _notebooks/Using_redflag_with_sklearn.ipynb + _notebooks/Tutorial.ipynb API reference diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index 153be5e..0000000 --- a/docs/make.bat +++ /dev/null @@ -1,35 +0,0 @@ -@ECHO OFF - -pushd %~dp0 - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set SOURCEDIR=. -set BUILDDIR=_build - -if "%1" == "" goto help - -%SPHINXBUILD% >NUL 2>NUL -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.https://www.sphinx-doc.org/ - exit /b 1 -) - -%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% -goto end - -:help -%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% - -:end -popd diff --git a/docs/notebooks/Tutorial.ipynb b/docs/notebooks/Tutorial.ipynb index 5fa283a..8830a0b 100644 --- a/docs/notebooks/Tutorial.ipynb +++ b/docs/notebooks/Tutorial.ipynb @@ -80,7 +80,7 @@ "X_scaled = scaler.transform(X)\n", "\n", "clf.fit(X_scaled, y)\n", - "clf.predict(X)" + "clf.predict(X) # <-- Oops, we predicted on unscaled data." ] }, { @@ -100,7 +100,7 @@ { "data": { "text/plain": [ - "array(['ms', 'ss'], dtype='" + "" ] }, - "execution_count": 11, + "execution_count": 10, "metadata": {}, "output_type": "execute_result" }, { "data": { - "image/png": "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X/5Ff/3rXwMeIy0trc4yeuihh6wZh9S895Hty0NSvZ8Vh8Ohl156yd/HhuXRnP+z4f6MENQWKygo0Ny5c7Vv3z7l5eXp8uXLSk9P14ULgSeMnzhxojwej3/66KOPwlRxwwYMGBBQ4+HDh/1tK1as0MqVK7V27VoVFhbK5XJp/PjxOnfuXBgrrquwsDBgDFdPsvOTn/zE38fGZXHhwgUNHDhQa9eurbe9Oa9/VlaWtm3bptzcXO3Zs0fnz5/XpEmTdOXKlVANo9FxXLx4Ufv379eyZcu0f/9+bd26VX/5y180efLkOn3nzJkTsIxee+21UJTv19TykJp+H9m+PCQF1O/xePT666/L4XDowQcfDOgX7uXRnP+zYf+MtPKaGAih0tJSI8kUFBT4582cOdPcf//94SuqGZ555hkzcODAettqa2uNy+UyL774on/epUuXTExMjHn11VdDVGHLzJ8/39x5552mtrbWGNM+loUks23bNv/t5rz+Z8+eNRERESY3N9ff55tvvjG33HKL2b59e8hq/75rx1GfP/3pT0aS+eqrr/zzRo0aZebPn9+2xV2H+sbR1PuovS6P+++/34wZMyZgnm3Lw5i6/2dt+IywRt2OXL0cZ2xsbMD8/Px89ejRQ/369dOcOXNUWloajvIadfz4cbndbiUnJ+uhhx7SF198IUkqKSmR1+tVevp319x1Op0aNWpUg5cptUF1dbU2b96sn//85wEXcmkPy+L7mvP6FxUVqaamJqCP2+1WSkqK1cuooqJCDoejznn833rrLcXFxWnAgAFatGiRdVtupMbfR+1xeZw+fVoffvihZs+eXafNtuVx7f9ZGz4jN8SZyW4GxhgtWLBA99xzj1JSUvzzMzIy9JOf/ES9e/dWSUmJli1bpjFjxqioqMiaswANGzZMb7zxhvr166fTp0/rhRde0MiRI1VcXOy/mMq1F1CJj4/XV199FY5ym+X999/X2bNnNWvWLP+89rAsrtWc19/r9apTp07q2rVrnT7XXgzHFpcuXdKvfvUrPfzwwwEXT/jpT3+q5ORkuVwuHTlyREuWLNGf//znOtcKCKem3kftcXls2rRJUVFRmjp1asB825ZHff9nbfiMENTtxLx583To0CHt2bMnYP706dP9f6ekpGjIkCHq3bu3PvzwwzofinDJyMjw/52amqoRI0bozjvv1KZNm/w7yVzPZUptsH79emVkZMjtdvvntYdl0ZCWvP62LqOamho99NBDqq2t1SuvvBLQNmfOHP/fKSkp6tu3r4YMGaL9+/frRz/6UahLrVdL30e2Lg9Jev311/XTn/5UnTt3Dphv2/Jo6P+sFN7PCJu+24HMzEx98MEH2r17t3r27Nlo34SEBPXu3VvHjx8PUXXXr0uXLkpNTdXx48f9e39fz2VKw+2rr77Szp079eijjzbarz0si+a8/i6XS9XV1SovL2+wjy1qamo0bdo0lZSUKC8vr8lLEf7oRz9SRESE1cvo2vdRe1oekvSHP/xBx44da/LzIoV3eTT0f9aGzwhBbTFjjObNm6etW7dq165dSk5ObvI+Z86c0cmTJ5WQkBCCClvG5/Pp6NGjSkhI8G/2+v6mrurqahUUFFh7mdINGzaoR48e+qd/+qdG+7WHZdGc13/w4MGKiIgI6OPxeHTkyBGrltHVkD5+/Lh27typbt26NXmf4uJi1dTUWL2Mrn0ftZflcdX69es1ePBgDRw4sMm+4VgeTf2fteIz0urd0dBmfvGLX5iYmBiTn59vPB6Pf7p48aIxxphz586ZhQsXmr1795qSkhKze/duM2LECPODH/zAVFZWhrn67yxcuNDk5+ebL774wuzbt89MmjTJREVFmS+//NIYY8yLL75oYmJizNatW83hw4fNjBkzTEJCglVjuOrKlSumV69eZvHixQHzbV4W586dMwcOHDAHDhwwkszKlSvNgQMH/HtDN+f1f/zxx03Pnj3Nzp07zf79+82YMWPMwIEDzeXLl60YR01NjZk8ebLp2bOnOXjwYMDnxefzGWOMOXHihHn22WdNYWGhKSkpMR9++KH54Q9/aAYNGmTNOJr7PrJ9eVxVUVFhbr31VrNu3bo697dleTT1f9aY8H9GCGqLSap32rBhgzHGmIsXL5r09HTTvXt3ExERYXr16mVmzpxpvv766/AWfo3p06ebhIQEExERYdxut5k6daopLi72t9fW1ppnnnnGuFwu43Q6zb333msOHz4cxoob9vHHHxtJ5tixYwHzbV4Wu3fvrvd9NHPmTGNM817/qqoqM2/ePBMbG2siIyPNpEmTQj62xsZRUlLS4Odl9+7dxhhjvv76a3Pvvfea2NhY06lTJ3PnnXeaJ5980pw5c8aacTT3fWT78rjqtddeM5GRkebs2bN17m/L8mjq/6wx4f+McJlLAAAsxm/UAABYjKAGAMBiBDUAABYjqAEAsBhBDQCAxQhqAAAsRlADAGAxghoAAIsR1AAAWIygBtAor9er+fPnq0+fPurcubPi4+N1zz336NVXX9XFixclSUlJSXI4HHI4HIqMjNQPf/hDvfTSS+LEh0DrcT1qAA364osv9OMf/1i33367li9frtTUVF2+fFl/+ctf9Prrr8vtdmvy5MmSpOeee05z5szRpUuXtHPnTv3iF79QdHS0HnvssTCPAmjfONc3gAZNnDhRxcXF+vzzz9WlS5c67cYYORwOJSUlKSsrS1lZWf62wYMHKykpSe+9914IKwZuPGz6BlCvM2fOaMeOHZo7d269IS1JDoejzjxjjPLz83X06FFFRES0dZnADY+gBlCvEydOyBiju+66K2B+XFycbrvtNt12221avHixf/7ixYt12223yel0avTo0TLG6Mknnwx12cANh6AG0Khr15r/9Kc/6eDBgxowYIB8Pp9//r/+67/q4MGDKigo0OjRo7V06VKNHDky1OUCNxx2JgNQrz59+sjhcOjzzz8PmH/HHXdIkiIjIwPmx8XFqU+fPurTp4/ee+899enTR8OHD9e4ceNCVjNwI2KNGkC9unXrpvHjx2vt2rW6cOHCdd23a9euyszM1KJFizhEC2glghpAg1555RVdvnxZQ4YM0TvvvKOjR4/q2LFj2rx5sz7//HN16NChwfvOnTtXx44dY69voJXY9A2gQXfeeacOHDig5cuXa8mSJTp16pScTqf69++vRYsW6Yknnmjwvt27d9cjjzyi7OxsTZ06VbfcwnoB0BIcRw0AgMX4igsAgMUIagAALEZQAwBgMYIaAACLEdQAAFiMoAYAwGIENQAAFiOoAQCwGEENAIDFCGoAACxGUAMAYLH/Hwbs+sPLYMiVAAAAAElFTkSuQmCC\n", + "image/png": "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TEzU8uXLOXEMl6Sph3iUl3/vx2oAwCzNCmm3233BM6QlqVOnTnK73S0uCu1HUw/xeGs2f/QBaL+aFdI33XSTZs+erbVr1youLk6S9O2332ru3LkaN26cTwtE8GpqK1liSxkAGtOskP7zn/+s2267TX379lV8fLwk6fjx4xoyZIhee+01nxaI4NXUVrLEljIANKZZIR0fH69PP/1UW7du1RdffCFJGjx4sFJSUnxaHAAA7dklnd29fft2JSYmyuVyyWaz6eabb9asWbM0a9YsjRo1Stdcc40+/vjj1qoVAIB25ZJC+rnnntOMGTMUHh5+QVtERIR+97vfaenSpT4rDgCA9uySQvp///d/NX78+AbbU1NTVVBQ0OKiAADAJR6TLi0trffSK8+Hdeyo7777rsVFofVx5jUAmO+SQvryyy/XoUOHNGDAgHrbDxw4oNjYWJ8UhtbFmdcAYL5L2t19yy23aP78+Tp37twFbT/88IOeeOIJ/epXv/JZcQAAtGeXtCX9+OOP6+2339bVV1+tmTNnauDAgZKkL774QsuXL1ddXZ3+5V/+pVUKBQCgvbmkLemYmBjt2rVLQ4YMUU5Oju644w7dcccdeuyxxzRkyBDt3LlTMTExF/15H330kW699VbFxcXJZrNp/fr1Xu333nuvbDab1/TzE9fKy8s1depUhYeHKzIyUtOnT9fp06cvZVgAABjpkm9mkpCQoE2bNun777/XkSNHZFmWrrrqKl122WWXvPAzZ85o2LBhuv/++zVp0qR6+4wfP16rVq3yvA4LC/Nqnzp1qkpKSrRlyxbV1NTovvvu04MPPqg1a9Zccj0AAJikWXcck6TLLrtMo0aNatHC09PTlZ6e3mifsLAw2e32ets+//xzbd68WXv37tXIkSMlSS+88IJuueUW/elPf/LcVxwAgGDUrOdJ+1Nubq6io6M1cOBAPfzwwzp58qSnLT8/X5GRkZ6AlqSUlBSFhIRoz549DX5mVVWVXC6X1wQAgGmMDunx48frr3/9q7Zt26Z/+7d/U15entLT01VXVydJcjqdio6O9npPx44dFRUVJWcj1wAvXrxYERERnun8Q0IAADBJs3d3+8PkyZM9PyclJWno0KG68sorlZub26JHYubk5CgrK8vz2uVyEdQAAOMYvSX9c/3791evXr105MgRSZLdbldZWZlXn9raWpWX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\n", "text/plain": [ "
" ] @@ -533,7 +533,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 11, "metadata": {}, "outputs": [ { @@ -542,7 +542,7 @@ "True" ] }, - "execution_count": 12, + "execution_count": 11, "metadata": {}, "output_type": "execute_result" } @@ -555,12 +555,12 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "This is order-dependent. That is, shuffling the data removes the correlation, but does not mean the records are independent — the only way around this issue is to split the data differently." + "This is order-dependent. That is, shuffling the data removes the correlation:" ] }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 12, "metadata": {}, "outputs": [ { @@ -569,7 +569,7 @@ "False" ] }, - "execution_count": 13, + "execution_count": 12, "metadata": {}, "output_type": "execute_result" } @@ -582,6 +582,13 @@ "rf.is_correlated(gr)" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "But this does not mean the records are independent — the only way around this issue is to split the data differently." + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -593,16 +600,16 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 13, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "array([0.42261124, 0.19923465, 0.31613598, 0.06121184])" + "array([0.42028113, 0.2001267 , 0.3180724 , 0.06151976])" ] }, - "execution_count": 14, + "execution_count": 13, "metadata": {}, "output_type": "execute_result" } @@ -631,14 +638,16 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "To make things as easy as possible, it would be nice to have some alarms in the pipeline. This won't be able to catch everything, for example if the data are shuffled and/or randomly sampled in a split, it might be very hard to spot self-correlation. I'm not sure how to alret the user to that kind of error, other than by potentially providing a wrapped version of `train_test_split()`.\n", + "To make things as easy as possible, it would be nice to have some smoke alarms in the pipeline. Redflag has some prebuilt smoke alarms, and you can also make your own.\n", + "\n", + "Redflag's smoke alarms won't be able to catch everything, however. For example if the data are shuffled and/or randomly sampled in a split, it might be very hard to spot self-correlation. I'm not sure how to alert the user to that kind of error, other than by potentially providing a wrapped version of `train_test_split()`.\n", "\n", "Anyway, let's split our data in a sensible way: by well." ] }, { "cell_type": "code", - "execution_count": 37, + "execution_count": 14, "metadata": {}, "outputs": [], "source": [ @@ -655,40 +664,34 @@ }, { "cell_type": "code", - "execution_count": 38, + "execution_count": 15, "metadata": {}, "outputs": [ { "data": { "text/html": [ - "
Pipeline(steps=[('rf.imbalance', ImbalanceDetector()),\n",
+       "
Pipeline(steps=[('rf.imbalance', ImbalanceDetector()),\n",
        "                ('rf.clip', ClipDetector()),\n",
        "                ('rf.correlation', CorrelationDetector()),\n",
-       "                ('rf.outlier',\n",
-       "                 OutlierDetector(p=0.9899999999999985,\n",
-       "                                 threshold=3.3682141715600706)),\n",
+       "                ('rf.outlier', OutlierDetector()),\n",
        "                ('rf.distributions', DistributionComparator()),\n",
-       "                ('rf.importance', ImportanceDetector())])
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
ImbalanceDetector()
ClipDetector()
CorrelationDetector()
OutlierDetector()
DistributionComparator()
ImportanceDetector()
" ], "text/plain": [ "Pipeline(steps=[('rf.imbalance', ImbalanceDetector()),\n", " ('rf.clip', ClipDetector()),\n", " ('rf.correlation', CorrelationDetector()),\n", - " ('rf.outlier',\n", - " OutlierDetector(p=0.9899999999999985,\n", - " threshold=3.3682141715600706)),\n", + " ('rf.outlier', OutlierDetector()),\n", " ('rf.distributions', DistributionComparator()),\n", " ('rf.importance', ImportanceDetector())])" ] }, - "execution_count": 38, + "execution_count": 15, "metadata": {}, "output_type": "execute_result" } @@ -706,40 +709,34 @@ }, { "cell_type": "code", - "execution_count": 39, + "execution_count": 16, "metadata": {}, "outputs": [ { "data": { "text/html": [ - "
Pipeline(steps=[('standardscaler', StandardScaler()),\n",
+       "
Pipeline(steps=[('standardscaler', StandardScaler()),\n",
        "                ('pipeline',\n",
        "                 Pipeline(steps=[('rf.imbalance', ImbalanceDetector()),\n",
        "                                 ('rf.clip', ClipDetector()),\n",
        "                                 ('rf.correlation', CorrelationDetector()),\n",
-       "                                 ('rf.outlier',\n",
-       "                                  OutlierDetector(p=0.9899999999999985,\n",
-       "                                                  threshold=3.3682141715600706)),\n",
+       "                                 ('rf.outlier', OutlierDetector()),\n",
        "                                 ('rf.distributions', DistributionComparator()),\n",
        "                                 ('rf.importance', ImportanceDetector())])),\n",
-       "                ('svc', SVC())])
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
ImbalanceDetector()
ClipDetector()
CorrelationDetector()
OutlierDetector()
DistributionComparator()
ImportanceDetector()
SVC()
" ], "text/plain": [ "Pipeline(steps=[('standardscaler', StandardScaler()),\n", @@ -747,15 +744,13 @@ " Pipeline(steps=[('rf.imbalance', ImbalanceDetector()),\n", " ('rf.clip', ClipDetector()),\n", " ('rf.correlation', CorrelationDetector()),\n", - " ('rf.outlier',\n", - " OutlierDetector(p=0.9899999999999985,\n", - " threshold=3.3682141715600706)),\n", + " ('rf.outlier', OutlierDetector()),\n", " ('rf.distributions', DistributionComparator()),\n", " ('rf.importance', ImportanceDetector())])),\n", " ('svc', SVC())])" ] }, - "execution_count": 39, + "execution_count": 16, "metadata": {}, "output_type": "execute_result" } @@ -769,7 +764,7 @@ }, { "cell_type": "code", - "execution_count": 40, + "execution_count": 17, "metadata": {}, "outputs": [ { @@ -777,43 +772,39 @@ "output_type": "stream", "text": [ "🚩 The labels are imbalanced by more than the threshold (0.420 > 0.400). See self.minority_classes_ for the minority classes.\n", - "🚩 Features 0, 1 may have clipped values.\n", - "🚩 Features 0, 1, 2 may have correlated values.\n", - "🚩 There are more outliers than expected in the training data (390 vs 72).\n", + "🚩 Features 0, 1 have samples that may be clipped.\n", + "🚩 Features 0, 1, 2 have samples that may be correlated.\n", + "🚩 There are more outliers than expected in the training data (316 vs 31).\n", "🚩 Feature 3 has low importance; check for relevance.\n" ] }, { "data": { "text/html": [ - "
Pipeline(steps=[('standardscaler', StandardScaler()),\n",
+       "
Pipeline(steps=[('standardscaler', StandardScaler()),\n",
        "                ('pipeline',\n",
        "                 Pipeline(steps=[('rf.imbalance', ImbalanceDetector()),\n",
        "                                 ('rf.clip', ClipDetector()),\n",
        "                                 ('rf.correlation', CorrelationDetector()),\n",
        "                                 ('rf.outlier',\n",
-       "                                  OutlierDetector(p=0.977050261730397,\n",
-       "                                                  threshold=3.3682141715600706)),\n",
+       "                                  OutlierDetector(threshold=3.643721188696941)),\n",
        "                                 ('rf.distributions', DistributionComparator()),\n",
        "                                 ('rf.importance', ImportanceDetector())])),\n",
-       "                ('svc', SVC())])
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
ImbalanceDetector()
ClipDetector()
CorrelationDetector()
OutlierDetector(threshold=3.643721188696941)
DistributionComparator()
ImportanceDetector()
SVC()
" ], "text/plain": [ "Pipeline(steps=[('standardscaler', StandardScaler()),\n", @@ -822,14 +813,13 @@ " ('rf.clip', ClipDetector()),\n", " ('rf.correlation', CorrelationDetector()),\n", " ('rf.outlier',\n", - " OutlierDetector(p=0.977050261730397,\n", - " threshold=3.3682141715600706)),\n", + " OutlierDetector(threshold=3.643721188696941)),\n", " ('rf.distributions', DistributionComparator()),\n", " ('rf.importance', ImportanceDetector())])),\n", " ('svc', SVC())])" ] }, - "execution_count": 40, + "execution_count": 17, "metadata": {}, "output_type": "execute_result" } @@ -840,16 +830,16 @@ }, { "cell_type": "code", - "execution_count": 41, + "execution_count": 18, "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ - "🚩 Feature 0 may have clipped values.\n", - "🚩 Features 0, 1, 2 may have correlated values.\n", - "🚩 There are more outliers than expected in the data (41 vs 18).\n", + "🚩 Feature 0 has samples that may be clipped.\n", + "🚩 Features 0, 1, 2 have samples that may be correlated.\n", + "🚩 There are more outliers than expected in the data (26 vs 8).\n", "🚩 Feature 2 has a distribution that is different from training.\n" ] }, @@ -1025,7 +1015,7 @@ " 'siltstone', 'siltstone', 'siltstone', 'siltstone'], dtype=object)" ] }, - "execution_count": 41, + "execution_count": 18, "metadata": {}, "output_type": "execute_result" } @@ -1034,12 +1024,70 @@ "pipe.predict(X_test)" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Making your own tests" + ] + }, { "cell_type": "code", - "execution_count": null, + "execution_count": 20, "metadata": {}, - "outputs": [], - "source": [] + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "🚩 Feature 3 has samples that are negative.\n" + ] + }, + { + "data": { + "text/html": [ + "
Pipeline(steps=[('detector',\n",
+       "                 Detector(func=<function BaseRedflagDetector.__init__.<locals>.<lambda> at 0x7f5de3dbeca0>,\n",
+       "                          warning='are negative')),\n",
+       "                ('svc', SVC())])
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
" + ], + "text/plain": [ + "Pipeline(steps=[('detector',\n", + " Detector(func=. at 0x7f5de3dbeca0>,\n", + " warning='are negative')),\n", + " ('svc', SVC())])" + ] + }, + "execution_count": 20, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from redflag import Detector\n", + "\n", + "def has_negative(x) -> bool:\n", + " \"\"\"Returns True, i.e. triggers, if any samples are negative.\"\"\"\n", + " return any(x < 0)\n", + "\n", + "negative_detector = Detector(has_negative, \"are negative\")\n", + "\n", + "pipe = make_pipeline(negative_detector, SVC()) # NB, no standardization.\n", + "pipe.fit(X_train, y_train)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The noise feature we added has negative values; the others are all positive, which is what we expect for these data.\n", + "\n", + "(Careful! All standardized features will have negative values.)" + ] } ], "metadata": { @@ -1058,7 +1106,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.8" + "version": "3.9.12" }, "vscode": { "interpreter": { diff --git a/docs/notebooks/Using_redflag_with_sklearn.ipynb b/docs/notebooks/Using_redflag_with_sklearn.ipynb index 9586c52..06a0420 100644 --- a/docs/notebooks/Using_redflag_with_sklearn.ipynb +++ b/docs/notebooks/Using_redflag_with_sklearn.ipynb @@ -269,7 +269,7 @@ }, { "data": { - "image/png": "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\n", + "image/png": "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Qw891FlDuCgXGu+pU6f0+eef64knntDnn3+ut99+W+Xl5br99tvP6btw4UKvfT5r1qzOKP+itbZ/JWncuHFeY3nzzTe92k3Zv5K8xllVVaVXX31VNptNeXl5Xv2CYf+25buntb/HZ86c0fjx49XQ0KDi4mK99tprWrlypebPn+/7gn3yNE4EjJqaGkuStW3bNs+6v/u7v7MeeeQR/xXlQ08++aQ1bNiwFttqa2ut8PBwa82aNZ51X375pSXJKikp6aQKO9Yjjzxi9e/f32pubrYsy6x9K8l65513PK+bm5sth8NhPfvss551tbW1lt1ut958803Lsixr//79liRr586dnj7vv/++ZbPZrO+++67Tam+Pn463JZ999pklyfr2228961JTU62lS5d2bHEdoKXxTpkyxbrjjjvO+x7T9+8dd9xh3XzzzV7rgnX//vS7py1/j9evX2+FhIRYLpfL02fZsmVWVFSUVV9f79P6mNkxTF1dnSQpNjbWa/0bb7yhXr16aciQISosLNSpU6f8UZ5PfP3110pMTFS/fv00efJkVVRUSJJKS0vV2NiorKwsT99BgwYpJSVFJSUl/irXZxoaGvT666/rgQce8HpgrUn79scOHTokl8vltT+jo6OVmZnp2Z8lJSWKiYnRyJEjPX2ysrIUEhKiHTt2dHrNvlZXVyebzXbOs/ueeeYZ9ezZU8OHD9ezzz7bIdP+nWXr1q2Ki4vTwIEDNWPGDB09etTTZvL+ra6u1l/+8hdNnTr1nLZg3L8//e5py9/jkpISZWRkeN0AOCcnR263W/v27fNpfUbcQRl/09zcrNmzZ+uGG27QkCFDPOt/9atfKTU1VYmJidq9e7fmzZun8vJyvf32236stn0yMzO1cuVKDRw4UFVVVXrqqaf085//XHv37pXL5VJERMQ5Xwzx8fFyuVz+KdiH3n33XdXW1uq+++7zrDNp3/7U2X320zuh/3h/ulwuxcXFebWHhYUpNjY26Pf56dOnNW/ePN19991eD0/87W9/q5/97GeKjY1VcXGxCgsLVVVVpSVLlvix2vYZN26cJkyYoLS0NB08eFCPPfaYcnNzVVJSotDQUKP372uvvaYePXqcc5g9GPdvS989bfl77HK5Wvz9PtvmS4Qdg+Tn52vv3r1e57BI8jq+nZGRoYSEBI0dO1YHDx5U//79O7vMS5Kbm+v5eejQocrMzFRqaqr+/Oc/q2vXrn6srOO98sorys3NVWJiomedSfsW/6+xsVH/8A//IMuytGzZMq+2goICz89Dhw5VRESEpk+frkWLFgXd4wcmTZrk+TkjI0NDhw5V//79tXXrVo0dO9aPlXW8V199VZMnT1aXLl281gfj/j3fd08g4TCWIWbOnKl169bpo48+Up8+fS7YNzMzU5J04MCBziitQ8XExOiqq67SgQMH5HA41NDQoNraWq8+1dXVcjgc/inQR7799ltt2rRJDz744AX7mbRvz+6zn1698eP96XA4VFNT49Xe1NSkY8eOBe0+Pxt0vv32W23cuNFrVqclmZmZampq0jfffNM5BXagfv36qVevXp7/vybuX0n65JNPVF5e3urvsxT4+/d83z1t+XvscDha/P0+2+ZLhJ0gZ1mWZs6cqXfeeUdbtmxRWlpaq+8pKyuTJCUkJHRwdR3vxIkTOnjwoBISEjRixAiFh4dr8+bNnvby8nJVVFTI6XT6scpLt2LFCsXFxWn8+PEX7GfSvk1LS5PD4fDan263Wzt27PDsT6fTqdraWpWWlnr6bNmyRc3NzZ7gF0zOBp2vv/5amzZtUs+ePVt9T1lZmUJCQs453BOMjhw5oqNHj3r+/5q2f8965ZVXNGLECA0bNqzVvoG6f1v77mnL32On06k9e/Z4BdqzAT89Pd3nBSOIzZgxw4qOjra2bt1qVVVVeZZTp05ZlmVZBw4csBYuXGjt2rXLOnTokPXee+9Z/fr1s0aPHu3nytvn0UcftbZu3WodOnTI+s///E8rKyvL6tWrl1VTU2NZlmU9/PDDVkpKirVlyxZr165dltPptJxOp5+rvjRnzpyxUlJSrHnz5nmtN2HfHj9+3Priiy+sL774wpJkLVmyxPriiy88Vx8988wzVkxMjPXee+9Zu3fvtu644w4rLS3N+uGHHzzbGDdunDV8+HBrx44d1qeffmpdeeWV1t133+2vIV3Qhcbb0NBg3X777VafPn2ssrIyr9/ns1emFBcXW0uXLrXKysqsgwcPWq+//rrVu3dv69577/XzyFp2ofEeP37c+t3vfmeVlJRYhw4dsjZt2mT97Gc/s6688krr9OnTnm2Ysn/PqqursyIjI61ly5ad8/5g2r+tffdYVut/j5uamqwhQ4ZY2dnZVllZmbVhwward+/eVmFhoc/rJewEOUktLitWrLAsy7IqKiqs0aNHW7GxsZbdbrcGDBhgzZ0716qrq/Nv4e00ceJEKyEhwYqIiLCSkpKsiRMnWgcOHPC0//DDD9ZvfvMb64orrrAiIyOtv//7v7eqqqr8WPGl++CDDyxJVnl5udd6E/btRx991OL/3ylTpliW9bfLz5944gkrPj7estvt1tixY8/5dzh69Kh19913W927d7eioqKs+++/3zp+/LgfRtO6C4330KFD5/19/uijjyzLsqzS0lIrMzPTio6Otrp06WINHjzYevrpp73CQSC50HhPnTplZWdnW71797bCw8Ot1NRUa9q0aV6XIVuWOfv3rJdeesnq2rWrVVtbe877g2n/tvbdY1lt+3v8zTffWLm5uVbXrl2tXr16WY8++qjV2Njo83pt/1c0AACAkThnBwAAGI2wAwAAjEbYAQAARiPsAAAAoxF2AACA0Qg7AADAaIQdAABgNMIOAAAwGmEHAAAYjbADIOi5XC498sgjGjBggLp06aL4+HjdcMMNWrZsmU6dOiVJ6tu3r2w2m2w2myIjI5WRkaF//dd/9XPlADpDmL8LAIBL8d///d+64YYbFBMTo6effloZGRmy2+3as2ePXn75ZSUlJen222+XJC1cuFDTpk3TqVOntGbNGk2bNk1JSUnKzc318ygAdCSejQUgqI0bN0779u3TV199pW7dup3TblmWbDab+vbtq9mzZ2v27Nmetp49e2rKlClasmRJJ1YMoLNxGAtA0Dp69Kg+/PBD5efntxh0JMlms52zrrm5Wf/+7/+u//mf/1FERERHlwnAzwg7AILWgQMHZFmWBg4c6LW+V69e6t69u7p376558+Z51s+bN0/du3eX3W7XXXfdpSuuuEIPPvhgZ5cNoJMRdgAY57PPPlNZWZmuvvpq1dfXe9bPnTtXZWVl2rJlizIzM7V06VINGDDAj5UC6AycoAwgaA0YMEA2m03l5eVe6/v16ydJ6tq1q9f6Xr16acCAARowYIDWrFmjjIwMjRw5Uunp6Z1WM4DOx8wOgKDVs2dP3XLLLfrTn/6kkydPXtR7k5OTNXHiRBUWFnZQdQACBWEHQFB78cUX1dTUpJEjR2r16tX68ssvVV5ertdff11fffWVQkNDz/veRx55RGvXrtWuXbs6sWIAnY1LzwEEvaqqKj399NP6y1/+oiNHjshutys9PV2//OUv9Zvf/EaRkZEtXnou/e3S9ZCQEK1fv94/xQPocIQdAABgNA5jAQAAoxF2AACA0Qg7AADAaIQdAABgNMIOAAAwGmEHAAAYjbADAACMRtgBAABGI+wAAACjEXYAAIDRCDsAAMBo/wtrVGNMX6SWNwAAAABJRU5ErkJggg==\n", "text/plain": [ "
" ] @@ -478,8 +478,8 @@ "output_type": "stream", "text": [ "🚩 The labels are imbalanced by more than the threshold (0.420 > 0.400). See self.minority_classes_ for the minority classes.\n", - "🚩 Features 0, 1 may have clipped values.\n", - "🚩 Features 0, 1, 2 may have correlated values.\n", + "🚩 Features 0, 1 have samples that may be clipped.\n", + "🚩 Features 0, 1, 2 have samples that may be correlated.\n", "🚩 There are more outliers than expected in the training data (349 vs 31).\n" ] }, @@ -552,8 +552,8 @@ "name": "stderr", "output_type": "stream", "text": [ - "🚩 Feature 0 may have clipped values.\n", - "🚩 Features 0, 1, 2 may have correlated values.\n", + "🚩 Feature 0 has samples that may be clipped.\n", + "🚩 Features 0, 1, 2 have samples that may be correlated.\n", "🚩 There are more outliers than expected in the data (30 vs 8).\n", "🚩 Feature 2 has a distribution that is different from training.\n" ] @@ -658,7 +658,7 @@ "name": "stderr", "output_type": "stream", "text": [ - "🚩 Feature 1 may have clipped values.\n", + "🚩 Feature 1 has samples that may be clipped.\n", "🚩 There are more outliers than expected in the training data (839 vs 626).\n" ] }, @@ -782,7 +782,7 @@ "output_type": "stream", "text": [ "🚩 There is a different number of minority classes (2) compared to the training data (4).\n", - "🚩 The minority classes (sandstone, dolomite) are different from those in the training data (dolomite, sandstone, mudstone, wackestone).\n" + "🚩 The minority classes (dolomite, sandstone) are different from those in the training data (dolomite, wackestone, mudstone, sandstone).\n" ] }, { @@ -806,6 +806,142 @@ "pipe.transform(X_test, y_test)" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Making your own smoke detector" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "You can pass a detection function to a generic `Detector`, along with a warning to emit when it is triggered:" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
Pipeline(steps=[('detector',\n",
+       "                 Detector(func=<function BaseRedflagDetector.__init__.<locals>.<lambda> at 0x7fc60c4dd3a0>,\n",
+       "                          warning='are NaNs')),\n",
+       "                ('svc', SVC())])
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
" + ], + "text/plain": [ + "Pipeline(steps=[('detector',\n", + " Detector(func=. at 0x7fc60c4dd3a0>,\n", + " warning='are NaNs')),\n", + " ('svc', SVC())])" + ] + }, + "execution_count": 19, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from redflag import Detector\n", + "import numpy as np\n", + "\n", + "def has_nans(x) -> bool:\n", + " \"\"\"Returns True, i.e. triggers, if any samples are NaN.\"\"\"\n", + " return any(np.isnan(x))\n", + "\n", + "negative_detector = Detector(has_nans, \"are NaNs\")\n", + "\n", + "pipe = make_pipeline(negative_detector, SVC())\n", + "pipe.fit(X_train, y_train)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "There are no NaNs.\n", + "\n", + "You can use `make_detector_pipeline` to combine several tests into a single pipeline." + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "🚩 Features 0, 2 have samples that fail has_outliers().\n" + ] + }, + { + "data": { + "text/html": [ + "
Pipeline(steps=[('standardscaler', StandardScaler()),\n",
+       "                ('pipeline',\n",
+       "                 Pipeline(steps=[('detector-1',\n",
+       "                                  Detector(func=<function BaseRedflagDetector.__init__.<locals>.<lambda> at 0x7fc60c4ddf70>,\n",
+       "                                           warning='fail has_nans()')),\n",
+       "                                 ('detector-2',\n",
+       "                                  Detector(func=<function BaseRedflagDetector.__init__.<locals>.<lambda> at 0x7fc60c4ddca0>,\n",
+       "                                           warning='fail has_outliers()'))])),\n",
+       "                ('svc', SVC())])
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
" + ], + "text/plain": [ + "Pipeline(steps=[('standardscaler', StandardScaler()),\n", + " ('pipeline',\n", + " Pipeline(steps=[('detector-1',\n", + " Detector(func=. at 0x7fc60c4ddf70>,\n", + " warning='fail has_nans()')),\n", + " ('detector-2',\n", + " Detector(func=. at 0x7fc60c4ddca0>,\n", + " warning='fail has_outliers()'))])),\n", + " ('svc', SVC())])" + ] + }, + "execution_count": 20, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from redflag import make_detector_pipeline\n", + "\n", + "def has_outliers(x):\n", + " \"\"\"Returns True, i.e. triggers, if any samples are negative.\"\"\"\n", + " return any(abs(x) > 5)\n", + "\n", + "detectors = make_detector_pipeline([has_nans, has_outliers])\n", + "\n", + "pipe = make_pipeline(StandardScaler(), detectors, SVC())\n", + "pipe.fit(X_train, y_train)" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -881,9 +1017,9 @@ ], "metadata": { "kernelspec": { - "display_name": "py39", + "display_name": "redflag", "language": "python", - "name": "py39" + "name": "redflag" }, "language_info": { "codemirror_mode": { @@ -895,7 +1031,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.9.13" + "version": "3.9.12" } }, "nbformat": 4, diff --git a/src/redflag/sklearn.py b/src/redflag/sklearn.py index 9ff0438..d7e5aa6 100644 --- a/src/redflag/sklearn.py +++ b/src/redflag/sklearn.py @@ -25,6 +25,7 @@ from sklearn import pipeline from sklearn.pipeline import Pipeline from sklearn.pipeline import _name_estimators +from sklearn.pipeline import make_pipeline from sklearn.covariance import EllipticEnvelope from scipy.stats import wasserstein_distance from scipy.stats import cumfreq @@ -66,14 +67,14 @@ def transform(self, X, y=None): positive = [i for i, feature in enumerate(X.T) if self.func(feature)] if n := len(positive): pos = ', '.join(str(i) for i in positive) - warnings.warn(f"🚩 Feature{'s' if n > 1 else ''} {pos} may have {self.warning}.") + warnings.warn(f"🚩 Feature{'' if n == 1 else 's'} {pos} {'has' if n == 1 else 'have'} samples that {self.warning}.") if (y is not None) and is_continuous(y): if np.asarray(y).ndim == 1: y_ = y.reshape(-1, 1) for i, target in enumerate(y_.T): if self.func(target): - warnings.warn(f"🚩 Target {i} may have {self.warning}.") + warnings.warn(f"🚩 Target {i} has samples that {self.warning}.") return X @@ -88,14 +89,14 @@ class ClipDetector(BaseRedflagDetector): >>> X = np.array([[2, 1], [3, 2], [4, 3], [5, 3]]) >>> pipe.fit_transform(X) # doctest: +SKIP redflag/sklearn.py::redflag.sklearn.ClipDetector - 🚩 Feature 1 may have clipped values. + 🚩 Feature 1 has samples that may be clipped. array([[2, 1], [3, 2], [4, 3], [5, 3]]) """ def __init__(self): - super().__init__(is_clipped, "clipped values") + super().__init__(is_clipped, "may be clipped") class CorrelationDetector(BaseRedflagDetector): @@ -109,7 +110,7 @@ class CorrelationDetector(BaseRedflagDetector): >>> X = np.stack([rng.uniform(size=20), np.sin(np.linspace(0, 1, 20))]).T >>> pipe.fit_transform(X) # doctest: +SKIP redflag/sklearn.py::redflag.sklearn.CorrelationDetector - 🚩 Feature 1 may have correlated values. + 🚩 Feature 1 has samples that may be correlated. array([[0.38077051, 0. ], [0.42977406, 0.05260728] ... @@ -117,7 +118,7 @@ class CorrelationDetector(BaseRedflagDetector): [0.7482485 , 0.84147098]]) """ def __init__(self): - super().__init__(is_correlated, "correlated values") + super().__init__(is_correlated, "may be correlated") class UnivariateOutlierDetector(BaseRedflagDetector): @@ -135,7 +136,7 @@ class UnivariateOutlierDetector(BaseRedflagDetector): >>> X = rng.normal(size=(1_000, 2)) >>> pipe.fit_transform(X) # doctest: +SKIP redflag/sklearn.py::redflag.sklearn.UnivariateOutlierDetector - 🚩 Features 0, 1 may have more outliers (in a univariate sense) than expected. + 🚩 Features 0, 1 have samples that are excess univariate outliers. array([[ 0.12573022, -0.13210486], [ 0.64042265, 0.10490012], [-0.53566937, 0.36159505], @@ -154,7 +155,7 @@ class UnivariateOutlierDetector(BaseRedflagDetector): [-0.90942756, 0.36922933]]) """ def __init__(self, **kwargs): - super().__init__(has_outliers, "more outliers (in a univariate sense) than expected", **kwargs) + super().__init__(has_outliers, "are excess univariate outliers", **kwargs) class MultivariateOutlierDetector(BaseEstimator, TransformerMixin): @@ -171,7 +172,7 @@ class MultivariateOutlierDetector(BaseEstimator, TransformerMixin): >>> X = rng.normal(size=(1_000, 2)) >>> pipe.fit_transform(X) # doctest: +SKIP redflag/sklearn.py::redflag.sklearn.MultivariateOutlierDetector - 🚩 Dataset may have more outliers (in a multivariate sense) than expected. + 🚩 Dataset has more multivariate outlier samples than expected. array([[ 0.12573022, -0.13210486], [ 0.64042265, 0.10490012], [-0.53566937, 0.36159505], @@ -210,13 +211,17 @@ def transform(self, X, y=None): outliers = has_outliers(X, p=self.p, threshold=self.threshold, factor=self.factor) if outliers: - warnings.warn(f"🚩 Dataset may have more outliers (in a multivariate sense) than expected.") + warnings.warn(f"🚩 Dataset has more multivariate outlier samples than expected.") if (y is not None) and is_continuous(y): if np.asarray(y).ndim == 1: y_ = y.reshape(-1, 1) + kind = 'univariate' + else: + y_ = y + kind = 'multivariate' if has_outliers(y_, p=self.p, threshold=self.threshold, factor=self.factor): - warnings.warn(f"🚩 Target may have more outliers (in a multivariate sense) than expected.") + warnings.warn(f"🚩 Target has more {kind} outlier samples than expected.") return X @@ -811,3 +816,36 @@ def make_rf_pipeline(*steps, memory=None, verbose=False): ("rf.importance", ImportanceDetector()), ] ) + + +class Detector(BaseRedflagDetector): + def __init__(self, func, warning=None): + if warning is None: + warning = f"fail custom func {func.__name__}()" + super().__init__(func, warning) + + +def make_detector_pipeline(funcs, warnings=None) -> Pipeline: + """ + Make a detector from one or more 'alarm' functions. + + Args: + funcs: Can be a sequence of functions returning True if a 1D array + meets some condition you want to trigger the alarm for. For example, + `has_negative = lambda x: np.any(x < 0)` to alert you to the + presence of negative values. Can also be a mappable of functions to + warnings. + warnings: The warnings corresponding to the functions. It's probably + safer to pass the functions with their warnings in a dict. + + Returns: + Pipeline + """ + detectors = [] + if isinstance(funcs, dict): + warnings = funcs.values() + elif warnings is None: + warnings = [None for _ in funcs] + for func, warn in zip(funcs, warnings): + detectors.append(Detector(func, warn)) + return make_pipeline(*detectors) diff --git a/tests/test_sklearn.py b/tests/test_sklearn.py index 4fb58ae..5868bc0 100644 --- a/tests/test_sklearn.py +++ b/tests/test_sklearn.py @@ -18,7 +18,7 @@ def test_clip_detector(): """ pipe = make_pipeline(rf.ClipDetector()) X = np.array([[2, 1], [3, 2], [4, 3], [5, 3]]) - with pytest.warns(UserWarning, match="Feature 1 may have clipped values."): + with pytest.warns(UserWarning, match="Feature 1 has samples that may be clipped."): pipe.fit_transform(X) # Does not warn: @@ -33,7 +33,22 @@ def test_correlation_detector(): pipe = make_pipeline(rf.CorrelationDetector()) rng = np.random.default_rng(0) X = np.stack([rng.uniform(size=20), np.sin(np.linspace(0, 1, 20))]).T - with pytest.warns(UserWarning, match="Feature 1 may have correlated values."): + with pytest.warns(UserWarning, match="Feature 1 has samples that may be correlated."): + pipe.fit_transform(X) + + +def test_custom_detector(): + """ + Checks for data which fails a user-supplied test. + """ + has_negative = lambda x: np.any(x < 0) + pipe = rf.make_detector_pipeline({has_negative: "are negative"}) + X = np.array([[-2, 1], [3, 2], [4, 3], [5, 4]]) + with pytest.warns(UserWarning, match="Feature 0 has samples that are negative."): + pipe.fit_transform(X) + + pipe = rf.make_detector_pipeline([has_negative]) + with pytest.warns(UserWarning, match="Feature 0 has samples that fail custom func"): pipe.fit_transform(X) @@ -62,7 +77,7 @@ def test_univariate_outlier_detector(): pipe = make_pipeline(rf.UnivariateOutlierDetector(factor=0.5)) rng = np.random.default_rng(0) X = rng.normal(size=1_000).reshape(-1, 1) - with pytest.warns(UserWarning, match="Feature 0 may have more outliers"): + with pytest.warns(UserWarning, match="Feature 0 has samples that are excess univariate outliers"): pipe.fit_transform(X) # Does not warn with factor of 2.5: @@ -75,7 +90,7 @@ def test_multivariate_outlier_detector(): pipe = make_pipeline(rf.MultivariateOutlierDetector(factor=0.5)) rng = np.random.default_rng(0) X = rng.normal(size=(1_000, 2)) - with pytest.warns(UserWarning, match="Dataset may have more outliers"): + with pytest.warns(UserWarning, match="Dataset has more multivariate outlier samples than expected."): pipe.fit_transform(X) # Does not warn with factor of 2.5: From 0c383972af1dedf984d7ee1e0e1079622ab27b0e Mon Sep 17 00:00:00 2001 From: kwinkunks Date: Thu, 6 Jul 2023 21:45:16 +0200 Subject: [PATCH 03/16] Fixes #34 --- CHANGELOG.md | 1 + docs/conf.py | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e249d22..318f18d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Added `make_detector_pipeline()` which can take sequences of functions and warnings (or a mapping of functions to warnings) and returns a `scikit-learn.pipeline.Pipeline` containing a `Detector` for each function. - Changed the wording slightly in the existing detectors. - Added a `Tutorial.ipynb` notebook to the docs. +- Added a **Copy** button to code blocks in the docs. ## 0.1.10, 21 November 2022 diff --git a/docs/conf.py b/docs/conf.py index d7482db..68b39b6 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -48,11 +48,12 @@ def setup(app): # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx.ext.githubpages', 'sphinxcontrib.apidoc', + 'sphinx.ext.githubpages', 'sphinx.ext.napoleon', - 'myst_nb', 'sphinx.ext.coverage', + 'sphinx_copybutton', + 'myst_nb', ] myst_enable_extensions = ["dollarmath", "amsmath"] From a90597ec7680401926e0e876a40bc396c5e0e748 Mon Sep 17 00:00:00 2001 From: kwinkunks Date: Sun, 30 Jul 2023 11:06:23 +0200 Subject: [PATCH 04/16] Allow all features to be important Fixes #41 --- src/redflag/sklearn.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/redflag/sklearn.py b/src/redflag/sklearn.py index d7e5aa6..6a133e8 100644 --- a/src/redflag/sklearn.py +++ b/src/redflag/sklearn.py @@ -697,15 +697,17 @@ def fit(self, X, y=None): importances = feature_importances(X, y, random_state=self.random_state) most_important = most_important_features(importances, threshold=self.threshold) - if (m := len(most_important)) <= 2: - most_str = ', '.join(str(i) for i in most_important) + M = X.shape[1] + + if (m := len(most_important)) <= 2 and (m < M): + most_str = ', '.join(str(i) for i in sorted(most_important)) warnings.warn(f"🚩 Feature{'' if m == 1 else 's'} {most_str} {'has' if m == 1 else 'have'} very high importance; check for leakage.") return self # Don't do this check if there were high-importance features (infer that the others are low.) least_important = least_important_features(importances, threshold=self.threshold) if (m := len(least_important)) > 0: - least_str = ', '.join(str(i) for i in least_important) + least_str = ', '.join(str(i) for i in sorted(least_important)) warnings.warn(f"🚩 Feature{'' if m == 1 else 's'} {least_str} {'has' if m == 1 else 'have'} low importance; check for relevance.") return self From 983039e204023cecded333a9f114d88f52f256aa Mon Sep 17 00:00:00 2001 From: kwinkunks Date: Sun, 3 Sep 2023 11:44:37 +0200 Subject: [PATCH 05/16] Add Python 3.12 to testing --- .github/workflows/build-test.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 8276100..781c3b8 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -14,16 +14,17 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Install dependencies run: | From 87e37e799a7b86fabdc75fb1ceb24ff87c11429f Mon Sep 17 00:00:00 2001 From: kwinkunks Date: Sun, 3 Sep 2023 11:45:55 +0200 Subject: [PATCH 06/16] Add multimodal detector --- src/redflag/sklearn.py | 57 +++++++++++++++++++++++++++++------------- tests/test_sklearn.py | 38 +++++++++++++++++++++------- 2 files changed, 69 insertions(+), 26 deletions(-) diff --git a/src/redflag/sklearn.py b/src/redflag/sklearn.py index 6a133e8..6f5d2b8 100644 --- a/src/redflag/sklearn.py +++ b/src/redflag/sklearn.py @@ -32,7 +32,9 @@ from sklearn.utils.metaestimators import available_if from .utils import is_clipped, proportion_to_stdev, stdev_to_proportion +from .utils import iter_groups from .target import is_continuous +from .distributions import is_multimodal from .independence import is_correlated from .outliers import has_outliers, expected_outliers from .imbalance import imbalance_degree, imbalance_ratio, minority_classes @@ -56,12 +58,6 @@ def __init__(self, func, warning, **kwargs): self.warning = warning def fit(self, X, y=None): - return self - - def transform(self, X, y=None): - """ - Checks X (and y, if it is continuous data) for suspect values. - """ X = check_array(X) positive = [i for i, feature in enumerate(X.T) if self.func(feature)] @@ -69,13 +65,21 @@ def transform(self, X, y=None): pos = ', '.join(str(i) for i in positive) warnings.warn(f"🚩 Feature{'' if n == 1 else 's'} {pos} {'has' if n == 1 else 'have'} samples that {self.warning}.") - if (y is not None) and is_continuous(y): - if np.asarray(y).ndim == 1: - y_ = y.reshape(-1, 1) + if y is not None: + y_ = np.asarray(y) + if y_.ndim == 1: + y_ = y_.reshape(-1, 1) for i, target in enumerate(y_.T): - if self.func(target): + if is_continuous(target) and self.func(target): warnings.warn(f"🚩 Target {i} has samples that {self.warning}.") + return self + + def transform(self, X, y=None): + """ + Can check X here, but y is not passed into here by `fit`. + """ + return X @@ -121,6 +125,17 @@ def __init__(self): super().__init__(is_correlated, "may be correlated") +class RegressionMultimodalDetector(BaseRedflagDetector): + """ + Transformer that detects features with non-unimodal distributions. In a + regression task, it considers the univariate distributions of the features + and the target. Do not use this detector for classification tasks, use + `MultimodalDetector` instead. + """ + def __init__(self): + super().__init__(is_multimodal, "may be multimodally distributed") + + class UnivariateOutlierDetector(BaseRedflagDetector): """ Transformer that detects if there are more than the expected number of @@ -499,8 +514,10 @@ def fit(self, X, y=None): self. """ # If there's no target or y is continuous (probably a regression), we're done. - if y is None or is_continuous(y): - warnings.warn("Target y is None or seems continuous, so no imbalance detection.") + if y is None: + return self + if is_continuous(y): + warnings.warn("Target y seems continuous, skipping imbalance detection.") return self methods = {'id': imbalance_degree, 'ir': imbalance_ratio} @@ -583,8 +600,10 @@ def fit(self, X, y=None): self. """ # If there's no target or y is continuous (probably a regression), we're done. - if y is None or is_continuous(y): - warnings.warn("Target y is None or seems continuous, so no imbalance detection.") + if y is None: + return self + if is_continuous(y): + warnings.warn("Target y seems continuous, skipping imbalance detection.") return self methods = {'id': imbalance_degree, 'ir': imbalance_ratio} @@ -613,8 +632,10 @@ def transform(self, X, y=None): X. """ # If there's no target or y is continuous (probably a regression), we're done. - if y is None or is_continuous(y): - warnings.warn("Target y is None or seems continuous, so no imbalance detection.") + if y is None: + return self + if is_continuous(y): + warnings.warn("Target y seems continuous, skipping imbalance detection.") return self methods = {'id': imbalance_degree, 'ir': imbalance_ratio} @@ -691,7 +712,7 @@ def fit(self, X, y=None): X. """ if y is None: - warnings.warn("Target y is None, so no importance detection.") + warnings.warn("Target y is None, skipping importance detection.") return self importances = feature_importances(X, y, random_state=self.random_state) @@ -706,6 +727,7 @@ def fit(self, X, y=None): # Don't do this check if there were high-importance features (infer that the others are low.) least_important = least_important_features(importances, threshold=self.threshold) + if (m := len(least_important)) > 0: least_str = ', '.join(str(i) for i in sorted(least_important)) warnings.warn(f"🚩 Feature{'' if m == 1 else 's'} {least_str} {'has' if m == 1 else 'have'} low importance; check for relevance.") @@ -813,6 +835,7 @@ def make_rf_pipeline(*steps, memory=None, verbose=False): ("rf.imbalance", ImbalanceDetector()), ("rf.clip", ClipDetector()), ("rf.correlation", CorrelationDetector()), + # ("rf.multimodal", MultimodalDetector()), ("rf.outlier", OutlierDetector()), ("rf.distributions", DistributionComparator()), ("rf.importance", ImportanceDetector()), diff --git a/tests/test_sklearn.py b/tests/test_sklearn.py index 5868bc0..2f0c8d9 100644 --- a/tests/test_sklearn.py +++ b/tests/test_sklearn.py @@ -37,6 +37,20 @@ def test_correlation_detector(): pipe.fit_transform(X) +def test_simple_multimodal_detector(): + """ + Checks for features with a multimodal distribution, considered across the + entire dataset (i.e. not per class). + """ + pipe = make_pipeline(rf.RegressionMultimodalDetector()) + rng = np.random.default_rng(0) + X1 = np.stack([rng.normal(size=80), rng.normal(size=80)]).T + X2 = np.stack([rng.normal(size=80), 3 + rng.normal(size=80)]).T + X = np.vstack([X1, X2]) + with pytest.warns(UserWarning, match="Feature 1 has samples that may be multimodally distributed."): + pipe.fit_transform(X) + + def test_custom_detector(): """ Checks for data which fails a user-supplied test. @@ -48,9 +62,17 @@ def test_custom_detector(): pipe.fit_transform(X) pipe = rf.make_detector_pipeline([has_negative]) - with pytest.warns(UserWarning, match="Feature 0 has samples that fail custom func"): + with pytest.warns(UserWarning, match="Feature 0 has samples that fail"): pipe.fit_transform(X) + detector = rf.Detector(has_negative) + X = np.random.random(size=(100, 2)) + y = np.random.random(size=100) - 0.1 + assert has_negative(y) + assert rf.is_continuous(y) + with pytest.warns(UserWarning, match="Target 0 has samples that fail"): + pipe.fit_transform(X, y) + def test_distribution_comparator(): """ @@ -135,12 +157,11 @@ def test_imbalance_detector(): # Warns about wrong kind of y (continuous): y = rng.normal(size=100) - with pytest.warns(UserWarning, match="Target y is None or seems continuous"): + with pytest.warns(UserWarning, match="Target y seems continuous"): pipe.fit_transform(X, y) - # Warns about wrong kind of y (None): - with pytest.warns(UserWarning, match="Target y is None or seems continuous"): - pipe.fit_transform(X) + # No warning if y is None, just skips. + pipe.fit_transform(X) # Raises error because method doesn't exist: with pytest.raises(ValueError) as e: @@ -179,12 +200,11 @@ def test_imbalance_comparator(): # Warns about wrong kind of y (continuous): y = rng.normal(size=100) - with pytest.warns(UserWarning, match="Target y is None or seems continuous"): + with pytest.warns(UserWarning, match="Target y seems continuous"): pipe.fit_transform(X, y) - # Warns about wrong kind of y (None): - with pytest.warns(UserWarning, match="Target y is None or seems continuous"): - pipe.fit_transform(X) + # No warning if y is None, just skips: + pipe.fit_transform(X) # Raises error because threshold is wrong. with pytest.raises(ValueError) as e: From 0dd935ba857bb99ba31a9102173a9a6312c5ad4b Mon Sep 17 00:00:00 2001 From: kwinkunks Date: Sun, 3 Sep 2023 11:47:02 +0200 Subject: [PATCH 07/16] Replace and deprecate is_standardized --- src/redflag/utils.py | 69 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 64 insertions(+), 5 deletions(-) diff --git a/src/redflag/utils.py b/src/redflag/utils.py index 6f73b29..5f849ff 100644 --- a/src/redflag/utils.py +++ b/src/redflag/utils.py @@ -21,17 +21,49 @@ from __future__ import annotations import warnings +import functools +import inspect from typing import Iterable, Any, Optional from numpy.typing import ArrayLike import numpy as np from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler -from scipy.stats import beta +from scipy import stats from scipy.optimize import fsolve from scipy.spatial.distance import pdist +def deprecated(instructions): + """ + Flags a method as deprecated. This decorator can be used to mark functions + as deprecated. It will result in a warning being emitted when the function + is used. + Args: + instructions (str): A human-friendly string of instructions, such + as: 'Please migrate to add_proxy() ASAP.' + Returns: + The decorated function. + """ + def decorator(func): + + @functools.wraps(func) + def wrapper(*args, **kwargs): + message = 'Call to deprecated function {}. {}'.format( + func.__name__, + instructions) + + frame = inspect.currentframe().f_back + + warnings.warn_explicit(message, + category=DeprecationWarning, + filename=inspect.getfile(frame.f_code), + lineno=frame.f_lineno) + return func(*args, **kwargs) + return wrapper + return decorator + + def flatten(L: list[Any]) -> Iterable[Any]: """ Flattens a list. For example: @@ -163,9 +195,10 @@ def split_and_standardize(X: ArrayLike, y: ArrayLike, random_state: Optional[int Returns: tuple of ndarray: X, X_train, X_val, y, y_train, y_val """ + X = np.asarray(X) X_train, X_val, y_train, y_val = train_test_split(X, y, random_state=random_state) - if not is_standardized(X): + if not all(is_standard_normal(x) for x in X.T): scaler = StandardScaler().fit(X) X = scaler.transform(X) scaler = StandardScaler().fit(X_train) @@ -257,7 +290,7 @@ def stdev_to_proportion(threshold: float, d: float=1, n: float=1e9) -> float: >>> stdev_to_proportion(5, d=10) 0.9946544947734935 """ - return float(beta.cdf(x=1/n, a=d/2, b=(n-d-1)/2, scale=1/threshold**2)) + return float(stats.beta.cdf(x=1/n, a=d/2, b=(n-d-1)/2, scale=1/threshold**2)) def proportion_to_stdev(p: float, d: float=1, n: float=1e9) -> float: @@ -298,7 +331,8 @@ def proportion_to_stdev(p: float, d: float=1, n: float=1e9) -> float: return float(r_hat) -def is_standardized(a: ArrayLike, atol: float=1e-5) -> bool: +@deprecated("Use is_standard_normal() instead.") +def is_standardized(a: ArrayLike, atol: float=1e-3) -> bool: """ Returns True if the feature has zero mean and standard deviation of 1. In other words, if the feature appears to be a Z-score. @@ -321,6 +355,31 @@ def is_standardized(a: ArrayLike, atol: float=1e-5) -> bool: return bool((np.abs(μ) < atol) and (np.abs(σ - 1) < atol)) +def is_standard_normal(a: ArrayLike, confidence: float=0.95) -> bool: + """ + Performs the Kolmogorov-Smirnov test for normality. Returns True if the + feature appears to be normally distributed, with a mean close to zero and + standard deviation close to 1. + + Args: + a (array): The data. + confidence (float): The confidence level of the test, default 0.95 + (95% confidence). + + Returns: + bool: True if the feature appears to have a standard normal distribution. + + Example: + >>> a = np.random.normal(size=1000) + >>> is_standard_normal(a) + True + >>> is_standard_normal(a + 1) + False + """ + ks = stats.kstest(a, stats.norm.cdf) + return ks.pvalue > (1 - confidence) + + def zscore(X: np.ndarray) -> np.ndarray: """ Transform array to Z-scores. If 2D, stats are computed @@ -433,7 +492,7 @@ def is_clipped(a: ArrayLike) -> bool: return (min_clips is not None) or (max_clips is not None) -def iter_groups(groups: ArrayLike) -> Iterator[np.ndarray]: +def iter_groups(groups: ArrayLike) -> Iterable[np.ndarray]: """ Allow iterating over groups, getting boolean array for each. From 216a34c517ea1cca1d9cf049cb081fdc6a8a411b Mon Sep 17 00:00:00 2001 From: kwinkunks Date: Sun, 3 Sep 2023 11:47:22 +0200 Subject: [PATCH 08/16] Fix importance --- src/redflag/importance.py | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/redflag/importance.py b/src/redflag/importance.py index 8e3e5be..adb7395 100644 --- a/src/redflag/importance.py +++ b/src/redflag/importance.py @@ -25,10 +25,10 @@ from sklearn.inspection import permutation_importance from sklearn.linear_model import Lasso from sklearn.ensemble import RandomForestRegressor -from sklearn.svm import SVR +from sklearn.neighbors import KNeighborsClassifier +from sklearn.neighbors import KNeighborsRegressor from sklearn.linear_model import LogisticRegression from sklearn.ensemble import RandomForestClassifier -from sklearn.svm import SVC from .target import is_continuous from .utils import split_and_standardize @@ -42,8 +42,8 @@ def feature_importances(X: ArrayLike, y: ArrayLike=None, Measure feature importances on a task, given X and y. Classification tasks are assessed with logistic regression, a random - forest, and SVM permutation importance. Regression tasks are assessed with - lasso regression, a random forest, and SVM permutation importance. In each + forest, and KNN permutation importance. Regression tasks are assessed with + lasso regression, a random forest, and KNN permutation importance. In each case, the `n` normalized importances with the most variance are averaged. Args: @@ -63,13 +63,13 @@ def feature_importances(X: ArrayLike, y: ArrayLike=None, appear in X. Examples: - >>> X = [[0, 0, 0], [0, 1, 1], [0, 2, 0], [0, 3, 1], [0, 4, 0], [0, 5, 1]] - >>> y = [5, 15, 25, 35, 45, 55] - >>> feature_importances(X, y, task='regression', random_state=0) - array([ 0. , 0.97811006, -0.19385077]) - >>> y = ['a', 'a', 'a', 'b', 'b', 'b'] - >>> feature_importances(X, y, task='classification', random_state=0) - array([ 0. , 0.89013985, -0.55680651]) + >>> X = [[0, 0, 0], [0, 1, 1], [0, 2, 0], [0, 3, 1], [0, 4, 0], [0, 5, 1], [0, 7, 0], [0, 8, 1], [0, 8, 0]] + >>> y = [5, 15, 25, 35, 45, 55, 80, 85, 90] + >>> feature_importances(X, y, task='regression', random_state=42) + array([0. , 0.99416839, 0.00583161]) + >>> y = ['a', 'a', 'a', 'b', 'b', 'b', 'c', 'c', 'c'] + >>> feature_importances(X, y, task='classification', random_state=42) + array([0. , 0.62908523, 0.37091477]) """ if y is None: raise NotImplementedError('Unsupervised importance is not yet implemented.') @@ -86,15 +86,18 @@ def feature_importances(X: ArrayLike, y: ArrayLike=None, if task == 'classification': imps.append(np.abs(LogisticRegression().fit(X, y).coef_.sum(axis=0))) imps.append(RandomForestClassifier(random_state=random_state).fit(X, y).feature_importances_) - model = SVC(random_state=random_state).fit(X_train, y_train) + model = KNeighborsClassifier().fit(X_train, y_train) r = permutation_importance(model, X_val, y_val, n_repeats=10, scoring='f1_weighted', random_state=random_state) imps.append(r.importances_mean) elif task == 'regression': + # Need data to be scaled, but don't necessarily want to scale entire dataset. imps.append(np.abs(Lasso().fit(X, y).coef_)) imps.append(RandomForestRegressor(random_state=random_state).fit(X, y).feature_importances_) - model = SVR().fit(X_train, y_train) + model = KNeighborsRegressor().fit(X_train, y_train) r = permutation_importance(model, X_val, y_val, n_repeats=10, scoring='neg_mean_squared_error', random_state=random_state) - imps.append(r.importances_mean) + if not all(r.importances_mean < 0): + r.importances_mean[r.importances_mean < 0] = 1e-9 + imps.append(r.importances_mean) imps = np.array(imps) From 1a5f77d7f98ab38c5812f922d0c190d6c4d8afe4 Mon Sep 17 00:00:00 2001 From: kwinkunks Date: Sun, 3 Sep 2023 11:47:47 +0200 Subject: [PATCH 09/16] Fix is_continuous --- src/redflag/target.py | 42 +++++++++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/src/redflag/target.py b/src/redflag/target.py index 641a74f..2866f7e 100644 --- a/src/redflag/target.py +++ b/src/redflag/target.py @@ -61,7 +61,7 @@ def is_continuous(a: ArrayLike, n: Optional[int]=None) -> bool: n (int): The number of potential categories. That is, if there are fewer than n unique values in the data, it is estimated to be categorical. Default: the square root of the sample size, which - is 10% of the data or 10_000, whichever is smaller. + is all the data or 10_000 random samples, whichever is smaller. Returns: bool: True if arr is probably best suited to regression. @@ -74,39 +74,51 @@ def is_continuous(a: ArrayLike, n: Optional[int]=None) -> bool: >>> import numpy as np >>> is_continuous(np.random.random(size=100)) True + >>> is_continuous(np.random.randint(0, 15, size=200)) + False """ - arr = np.array(a) + arr = np.asarray(a) if not is_numeric(arr): return False + # Now we are dealing with numbers that could represent categories. + + if is_binary(arr): + return False + # Starting with this and having the uplifts be 0.666 means # that at least 2 tests must trigger to get over 0.5. - p = 0.333 - - N = max(min(len(arr)//10, 10_000), 10) - sample = np.random.choice(arr, size=N, replace=False) + p = 1 / 3 + + # Take a sample if array is large. + if arr.size < 10_000: + sample = arr + else: + sample = np.random.choice(arr, size=10_000, replace=False) if n is None: - n = np.sqrt(len(sample)) + n = np.sqrt(sample.size) - # Check if floats (proper floats, ). + # Check if floats. if np.issubdtype(sample.dtype, np.floating): # If not ints in disguise. if not np.all([xi.is_integer() for xi in np.unique(sample)]): - p = update_p(p, 0.666, 0.666) - + p = update_p(p, 2/3, 2/3) + # If low precision. - if np.all((100*sample).astype(int) - 100*sample < 1e-12): - p = update_p(p, 0.666, 0.666) + if np.all((sample.astype(int) - sample) < 1e-3): + p = update_p(p, 2/3, 2/3) + # If many unique values. if np.unique(sample).size > n: - p = update_p(p, 0.666, 0.666) + p = update_p(p, 2/3, 2/3) - many_gap_sizes = np.unique(np.diff(sample)).size > n + # If many sizes of gaps between numbers. + many_gap_sizes = np.unique(np.diff(np.sort(sample))).size > n if many_gap_sizes: - p = update_p(p, 0.666, 0.666) + p = update_p(p, 2/3, 2/3) return p > 0.5 From 6b096e63ed35d206015bca09ae7817edb52aab64 Mon Sep 17 00:00:00 2001 From: kwinkunks Date: Sun, 3 Sep 2023 11:48:38 +0200 Subject: [PATCH 10/16] Add multimodal and fix KDE stuff --- src/redflag/distributions.py | 102 +++++++++++++++++++++++++---------- 1 file changed, 75 insertions(+), 27 deletions(-) diff --git a/src/redflag/distributions.py b/src/redflag/distributions.py index 6e8416d..c421b83 100644 --- a/src/redflag/distributions.py +++ b/src/redflag/distributions.py @@ -34,7 +34,7 @@ from sklearn.neighbors import KernelDensity from sklearn.model_selection import GridSearchCV -from .utils import is_standardized +from .utils import is_standard_normal from .utils import iter_groups @@ -256,9 +256,9 @@ def wasserstein(X: ArrayLike, except AttributeError: # It's probably a 1D array or list. pass - + if stacked: - if not is_standardized(first): + if not is_standard_normal(first): warnings.warn('First group does not appear to be standardized.', stacklevel=2) groups = np.hstack([len(dataset)*[i] for i, dataset in enumerate(X)]) X = np.vstack(X) @@ -267,7 +267,7 @@ def wasserstein(X: ArrayLike, X = np.asarray(X) if X.ndim != 2: raise ValueError("X must be a 2D array-like.") - + if groups is None: raise ValueError("Must provide a 1D array of group labels if X is a 2D array.") n_groups = np.unique(groups).size @@ -303,6 +303,10 @@ def bw_silverman(a: ArrayLike) -> float: """ Calculate the Silverman bandwidth. + Silverman, BW (1981), "Using kernel density estimates to investigate + multimodality", Journal of the Royal Statistical Society. Series B Vol. 43, + No. 1 (1981), pp. 97-99. + Args: a (array): The data. @@ -350,12 +354,20 @@ def cv_kde(a: ArrayLike, n_bandwidths: int=20, cv: int=10) -> float: Returns: float. The optimal bandwidth. - Examples: - >>> data = [1, 1, 1, 2, 2, 1, 1, 2, 2, 3, 2, 2, 2, 3, 3] - >>> abs(cv_kde(data, n_bandwidths=3, cv=3) - 0.290905379576344) < 1e-9 - True + Example: + >>> rng = np.random.default_rng(42) + >>> data = rng.normal(size=100) + >>> cv_kde(data, n_bandwidths=3, cv=3) + 0.5212113989811242 """ - a = np.asarray(a).reshape(-1, 1) + a = np.asarray(a) + if not is_standard_normal(a): + warnings.warn('Data does not appear to be standardized, the KDE may be a poor fit.', stacklevel=2) + if a.ndim == 1: + a = a.reshape(-1, 1) + elif a.ndim >= 2: + raise ValueError("Data must be 1D.") + silverman = bw_silverman(a) scott = bw_scott(a) start = min(silverman, scott)/2 @@ -378,22 +390,30 @@ def fit_kde(a: ArrayLike, bandwidth: float=1.0, kernel: str='gaussian') -> tuple Returns: tuple: (x, kde). - Examples: - >>> data = [-3, 1, -2, -2, -2, -2, 1, 2, 2, 1, 1, 2, 0, 0, 2, 2, 3, 3] + Example: + >>> rng = np.random.default_rng(42) + >>> data = rng.normal(size=100) >>> x, kde = fit_kde(data) >>> x[0] - -4.5 - >>> abs(kde[0] - 0.011092399847113) < 1e-9 - True + -3.2124714013056916 + >>> kde[0] + 0.014367259502733645 >>> len(kde) 200 """ a = np.asarray(a) + if not is_standard_normal(a): + warnings.warn('Data does not appear to be standardized, the KDE may be a poor fit.', stacklevel=2) + if a.ndim == 1: + a = a.reshape(-1, 1) + elif a.ndim >= 2: + raise ValueError("Data must be 1D.") model = KernelDensity(kernel=kernel, bandwidth=bandwidth) - model.fit(a.reshape(-1, 1)) - mima = 1.5 * np.abs(a).max() + model.fit(a) + mima = 1.5 * bandwidth * np.abs(a).max() x = np.linspace(-mima, mima, 200).reshape(-1, 1) log_density = model.score_samples(x) + return np.squeeze(x), np.exp(log_density) @@ -403,19 +423,20 @@ def get_kde(a: ArrayLike, method: str='scott') -> tuple[np.ndarray, np.ndarray]: Args: a (array): The data. - method (str): The rule of thumb for bandwidth estimation. - Default 'scott'. + method (str): The rule of thumb for bandwidth estimation. Must be one + of 'silverman', 'scott', or 'cv'. Default 'scott'. Returns: tuple: (x, kde). Examples: - >>> data = [-3, 1, -2, -2, -2, -2, 1, 2, 2, 1, 1, 2, 0, 0, 2, 2, 3, 3] + >>> rng = np.random.default_rng(42) + >>> data = rng.normal(size=100) >>> x, kde = get_kde(data) >>> x[0] - -4.5 - >>> abs(kde[0] - 0.0015627693633590066) < 1e-09 - True + -1.354649738246933 + >>> kde[0] + 0.162332012191087 >>> len(kde) 200 """ @@ -462,8 +483,8 @@ def kde_peaks(a: ArrayLike, method: str='scott', threshold: float=0.1) -> tuple[ Args: a (array): The data. - method (str): The rule of thumb for bandwidth estimation. - Default 'scott'. + method (str): The rule of thumb for bandwidth estimation. Must be one + of 'silverman', 'scott', or 'cv'. Default 'scott'. threshold (float): The threshold for peak amplitude. Default 0.1. Returns: @@ -471,11 +492,38 @@ def kde_peaks(a: ArrayLike, method: str='scott', threshold: float=0.1) -> tuple[ the peaks. Examples: - >>> data = [-3, 1, -2, -2, -2, -2, 1, 2, 2, 1, 1, 2, 0, 0, 2, 2, 3, 3] + >>> rng = np.random.default_rng(42) + >>> data = np.concatenate([rng.normal(size=100)-2, rng.normal(size=100)+2]) >>> x_peaks, y_peaks = kde_peaks(data) >>> x_peaks - array([-2.05778894, 1.74120603]) + array([-1.67243035, 1.88998226]) >>> y_peaks - array([0.15929031, 0.24708215]) + array([0.22014721, 0.19729456]) """ return find_large_peaks(*get_kde(a, method), threshold=threshold) + + +def is_multimodal(a: ArrayLike, method: str='scott', threshold: float=0.1) -> bool: + """ + Test if the data is multimodal. + + Args: + a (array): The data. + method (str): The rule of thumb for bandwidth estimation. Must be one + of 'silverman', 'scott', or 'cv'. Default 'scott'. + threshold (float): The threshold for peak amplitude. Default 0.1. + + Returns: + bool: True if the data is multimodal. + + Examples: + >>> rng = np.random.default_rng(42) + >>> data = rng.normal(size=100) + >>> is_multimodal(data) + False + >>> data = np.concatenate([rng.normal(size=100)-2, rng.normal(size=100)+2]) + >>> is_multimodal(data) + True + """ + x, y = kde_peaks(a, method=method, threshold=threshold) + return len(x) > 1 From b5b9250a19018754056a3b550c8df59306ad003e Mon Sep 17 00:00:00 2001 From: kwinkunks Date: Sun, 3 Sep 2023 11:48:47 +0200 Subject: [PATCH 11/16] Add changes --- CHANGELOG.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 318f18d..c0538e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,17 @@ # Changelog -## 0.1.11, summer 2023 +## 0.2.0, 3 September 2023 -- Added custom 'alarm' `Detector`, which can be instantiated with a function and a warning to emit when the function returns True for a 1D array. +- Moved to something more closely resembling semantic versioning, which is the main reason this is version 0.2.0. +- Builds and tests on Python 3.11 have been successful, so now supporting this version. Started testing on Python 3.12, which is not supported for the time being. +- Added custom 'alarm' `Detector`, which can be instantiated with a function and a warning to emit when the function returns True for a 1D array. You can easily write your own detectors with this class. - Added `make_detector_pipeline()` which can take sequences of functions and warnings (or a mapping of functions to warnings) and returns a `scikit-learn.pipeline.Pipeline` containing a `Detector` for each function. -- Changed the wording slightly in the existing detectors. +- Added `RegressionMultimodalDetector` to allow detection of non-unimodal distributions in features, when considered across the entire dataset. (Coming soon, a similar detector for classification tasks that will partition the data by class.) +- Redefined `is_standardized` (deprecated) as `is_standard_normal`, which implements the Kolmogorov–Smirnov test. It seems more reliable than assuming the data will have a mean of almost exactly 0 and standard deviation of exactly 1, when all we really care about is that the feature is roughly normal. +- Changed the wording slightly in the existing detector warning messages. +- No longer warning if `y` is `None` in, eg, `ImportanceDetector`, since you most likely know this. +- Some changes to `ImportanceDetector`. It now uses KNN estimators instead of SVMs as the third measure of importance; the SVMs were too unstable, causing numerical issues. It also now requires that the number of important features is less than the total number of features to be triggered. So if you have 2 features and both are important, it does not trigger. +- Improved `is_continuous()` which was erroneously classifying integer arrays with many consecutive values as non-continuous. - Added a `Tutorial.ipynb` notebook to the docs. - Added a **Copy** button to code blocks in the docs. From ad197c2561c3e6a0e435c330310da9173f3b1ec4 Mon Sep 17 00:00:00 2001 From: kwinkunks Date: Sun, 3 Sep 2023 17:53:55 +0200 Subject: [PATCH 12/16] Upgraded env, getting tests passing --- src/redflag/distributions.py | 14 +++++++------- src/redflag/utils.py | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/redflag/distributions.py b/src/redflag/distributions.py index c421b83..ec362b2 100644 --- a/src/redflag/distributions.py +++ b/src/redflag/distributions.py @@ -258,7 +258,7 @@ def wasserstein(X: ArrayLike, pass if stacked: - if not is_standard_normal(first): + if not is_standard_normal(first.flat): warnings.warn('First group does not appear to be standardized.', stacklevel=2) groups = np.hstack([len(dataset)*[i] for i, dataset in enumerate(X)]) X = np.vstack(X) @@ -309,7 +309,7 @@ def bw_silverman(a: ArrayLike) -> float: Args: a (array): The data. - + Returns: float: The Silverman bandwidth. @@ -325,7 +325,7 @@ def bw_silverman(a: ArrayLike) -> float: def bw_scott(a: ArrayLike) -> float: """ Calculate the Scott bandwidth. - + Args: a (array): The data. @@ -396,8 +396,8 @@ def fit_kde(a: ArrayLike, bandwidth: float=1.0, kernel: str='gaussian') -> tuple >>> x, kde = fit_kde(data) >>> x[0] -3.2124714013056916 - >>> kde[0] - 0.014367259502733645 + >>> kde[0] - 0.014367259502733645 < 1e-9 + True >>> len(kde) 200 """ @@ -435,8 +435,8 @@ def get_kde(a: ArrayLike, method: str='scott') -> tuple[np.ndarray, np.ndarray]: >>> x, kde = get_kde(data) >>> x[0] -1.354649738246933 - >>> kde[0] - 0.162332012191087 + >>> kde[0] - 0.162332012191087 < 1e-9 + True >>> len(kde) 200 """ diff --git a/src/redflag/utils.py b/src/redflag/utils.py index 5f849ff..6dcec1b 100644 --- a/src/redflag/utils.py +++ b/src/redflag/utils.py @@ -371,12 +371,12 @@ def is_standard_normal(a: ArrayLike, confidence: float=0.95) -> bool: Example: >>> a = np.random.normal(size=1000) - >>> is_standard_normal(a) + >>> is_standard_normal(a, confidence=0.9) True >>> is_standard_normal(a + 1) False """ - ks = stats.kstest(a, stats.norm.cdf) + ks = stats.kstest(a, 'norm') return ks.pvalue > (1 - confidence) From 6f61b4730313f00e03856501224962c747728d4a Mon Sep 17 00:00:00 2001 From: kwinkunks Date: Sun, 3 Sep 2023 17:54:46 +0200 Subject: [PATCH 13/16] Update version method after pkg_resources deprecated --- src/redflag/__init__.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/redflag/__init__.py b/src/redflag/__init__.py index c482b84..1b9cf06 100644 --- a/src/redflag/__init__.py +++ b/src/redflag/__init__.py @@ -11,17 +11,11 @@ from .importance import * from .outliers import * +# From https://github.com/pypa/setuptools_scm +from importlib.metadata import version, PackageNotFoundError -from pkg_resources import get_distribution, DistributionNotFound try: - VERSION = get_distribution(__name__).version -except DistributionNotFound: - try: - from ._version import version as VERSION - except ImportError: - raise ImportError( - "Failed to find (autogenerated) _version.py. " - "This might be because you are installing from GitHub's tarballs, " - "use the PyPI ones." - ) -__version__ = VERSION + __version__ = version("package-name") +except PackageNotFoundError: + # package is not installed + pass From bd1e57e80aefbb9c39e0b90f63ca3389e9faf1a1 Mon Sep 17 00:00:00 2001 From: kwinkunks Date: Sun, 3 Sep 2023 22:31:25 +0200 Subject: [PATCH 14/16] Pin NumPy --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index c4d3904..9584082 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ classifiers = [ ] dependencies = [ + "numpy<2.0", # NumPy 2 will likely break some things. "scipy!=1.10.0", # Bug in stats.powerlaw. "scikit-learn", ] From c367b7a10f93787e4bc2855ea1aa7758430fd64b Mon Sep 17 00:00:00 2001 From: kwinkunks Date: Sun, 3 Sep 2023 22:31:40 +0200 Subject: [PATCH 15/16] Trying to get tests to pass --- src/redflag/distributions.py | 8 ++++---- src/redflag/importance.py | 8 ++++---- src/redflag/utils.py | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/redflag/distributions.py b/src/redflag/distributions.py index ec362b2..f406304 100644 --- a/src/redflag/distributions.py +++ b/src/redflag/distributions.py @@ -394,8 +394,8 @@ def fit_kde(a: ArrayLike, bandwidth: float=1.0, kernel: str='gaussian') -> tuple >>> rng = np.random.default_rng(42) >>> data = rng.normal(size=100) >>> x, kde = fit_kde(data) - >>> x[0] - -3.2124714013056916 + >>> x[0] + 3.2124714013056916 < 1e-9 + True >>> kde[0] - 0.014367259502733645 < 1e-9 True >>> len(kde) @@ -433,8 +433,8 @@ def get_kde(a: ArrayLike, method: str='scott') -> tuple[np.ndarray, np.ndarray]: >>> rng = np.random.default_rng(42) >>> data = rng.normal(size=100) >>> x, kde = get_kde(data) - >>> x[0] - -1.354649738246933 + >>> x[0] + 1.354649738246933 < 1e-9 + True >>> kde[0] - 0.162332012191087 < 1e-9 True >>> len(kde) diff --git a/src/redflag/importance.py b/src/redflag/importance.py index adb7395..45f4171 100644 --- a/src/redflag/importance.py +++ b/src/redflag/importance.py @@ -84,17 +84,17 @@ def feature_importances(X: ArrayLike, y: ArrayLike=None, # Train three models and gather the importances. imps: list = [] if task == 'classification': - imps.append(np.abs(LogisticRegression().fit(X, y).coef_.sum(axis=0))) + imps.append(np.abs(LogisticRegression(random_state=random_state).fit(X, y).coef_.sum(axis=0))) imps.append(RandomForestClassifier(random_state=random_state).fit(X, y).feature_importances_) model = KNeighborsClassifier().fit(X_train, y_train) - r = permutation_importance(model, X_val, y_val, n_repeats=10, scoring='f1_weighted', random_state=random_state) + r = permutation_importance(model, X_val, y_val, n_repeats=8, scoring='f1_weighted', random_state=random_state) imps.append(r.importances_mean) elif task == 'regression': # Need data to be scaled, but don't necessarily want to scale entire dataset. - imps.append(np.abs(Lasso().fit(X, y).coef_)) + imps.append(np.abs(Lasso(random_state=random_state).fit(X, y).coef_)) imps.append(RandomForestRegressor(random_state=random_state).fit(X, y).feature_importances_) model = KNeighborsRegressor().fit(X_train, y_train) - r = permutation_importance(model, X_val, y_val, n_repeats=10, scoring='neg_mean_squared_error', random_state=random_state) + r = permutation_importance(model, X_val, y_val, n_repeats=8, scoring='neg_mean_squared_error', random_state=random_state) if not all(r.importances_mean < 0): r.importances_mean[r.importances_mean < 0] = 1e-9 imps.append(r.importances_mean) diff --git a/src/redflag/utils.py b/src/redflag/utils.py index 6dcec1b..cc403f9 100644 --- a/src/redflag/utils.py +++ b/src/redflag/utils.py @@ -370,7 +370,7 @@ def is_standard_normal(a: ArrayLike, confidence: float=0.95) -> bool: bool: True if the feature appears to have a standard normal distribution. Example: - >>> a = np.random.normal(size=1000) + >>> a = np.random.normal(size=2000) >>> is_standard_normal(a, confidence=0.9) True >>> is_standard_normal(a + 1) From 9224285a733303304d48bf6b8be39fe4e9f4724c Mon Sep 17 00:00:00 2001 From: kwinkunks Date: Sun, 3 Sep 2023 22:42:47 +0200 Subject: [PATCH 16/16] change test to assert condition not equality --- README.md | 2 -- src/redflag/importance.py | 5 +++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d4ec102..9ad96a7 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,6 @@ 🚩 `redflag` aims to be an automatic safety net for machine learning datasets. The vision is to accept input of a Pandas `DataFrame` or NumPy `ndarray` (one for each of the input `X` and target `y` in a machine learning task). `redflag` will provide an analysis of each feature, and of the target, including aspects such as class imbalance, leakage, outliers, anomalous data patterns, threats to the IID assumption, and so on. The goal is to complement other projects like `pandas-profiling` and `greatexpectations`. -⚠️ **This project is very rough and does not do much yet. The API will very likely change without warning. Please consider contributing!** - ## Installation diff --git a/src/redflag/importance.py b/src/redflag/importance.py index 45f4171..920deab 100644 --- a/src/redflag/importance.py +++ b/src/redflag/importance.py @@ -68,8 +68,9 @@ def feature_importances(X: ArrayLike, y: ArrayLike=None, >>> feature_importances(X, y, task='regression', random_state=42) array([0. , 0.99416839, 0.00583161]) >>> y = ['a', 'a', 'a', 'b', 'b', 'b', 'c', 'c', 'c'] - >>> feature_importances(X, y, task='classification', random_state=42) - array([0. , 0.62908523, 0.37091477]) + >>> x0, x1, x2 = feature_importances(X, y, task='classification', random_state=42) + >>> x1 > x2 > x0 # See Issue #49 for why this test is like this. + True """ if y is None: raise NotImplementedError('Unsupervised importance is not yet implemented.')