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: | diff --git a/CHANGELOG.md b/CHANGELOG.md index f3e29be..c0538e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,19 @@ # Changelog -## 0.1.11, in development - -- Coming soon... +## 0.2.0, 3 September 2023 + +- 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. +- 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. ## 0.1.10, 21 November 2022 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/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"] diff --git a/docs/index.rst b/docs/index.rst index 5669fc3..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 @@ -82,5 +83,5 @@ Indices and tables PyPI releases Code in GitHub Issue tracker - Community guidelines - Scienxlab + Community guidelines + Scienxlab 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/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..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", ] @@ -46,7 +47,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/__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 diff --git a/src/redflag/distributions.py b/src/redflag/distributions.py index f7eea5d..f406304 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 @@ -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.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) @@ -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,9 +303,13 @@ 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. - + Returns: float: The Silverman bandwidth. @@ -321,7 +325,7 @@ def bw_silverman(a: ArrayLike) -> float: def bw_scott(a: ArrayLike) -> float: """ Calculate the Scott bandwidth. - + 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 + >>> x[0] + 3.2124714013056916 < 1e-9 + True + >>> kde[0] - 0.014367259502733645 < 1e-9 True >>> 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,18 +423,19 @@ 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 + >>> x[0] + 1.354649738246933 < 1e-9 + True + >>> kde[0] - 0.162332012191087 < 1e-9 True >>> 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 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..920deab 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 @@ -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,14 @@ 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'] + >>> 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.') @@ -84,17 +85,20 @@ 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 = SVC(random_state=random_state).fit(X_train, y_train) - r = permutation_importance(model, X_val, y_val, n_repeats=10, scoring='f1_weighted', random_state=random_state) + model = KNeighborsClassifier().fit(X_train, y_train) + 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': - imps.append(np.abs(Lasso().fit(X, y).coef_)) + # Need data to be scaled, but don't necessarily want to scale entire dataset. + 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 = SVR().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) + model = KNeighborsRegressor().fit(X_train, y_train) + 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) imps = np.array(imps) 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..6f5d2b8 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 @@ -25,13 +25,16 @@ 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 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 @@ -55,25 +58,27 @@ 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)] 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) + 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): - warnings.warn(f"🚩 Target {i} may have {self.warning}.") + 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 @@ -88,14 +93,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 +114,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 +122,18 @@ class CorrelationDetector(BaseRedflagDetector): [0.7482485 , 0.84147098]]) """ def __init__(self): - super().__init__(is_correlated, "correlated values") + 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): @@ -135,7 +151,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 +170,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 +187,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 +226,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 @@ -494,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} @@ -578,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} @@ -608,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} @@ -686,21 +712,24 @@ 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) 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 @@ -806,8 +835,42 @@ 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()), ] ) + + +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/src/redflag/target.py b/src/redflag/target.py index 1acbf8f..2866f7e 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 @@ -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 diff --git a/src/redflag/utils.py b/src/redflag/utils.py index 7151a5d..cc403f9 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 @@ -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=2000) + >>> is_standard_normal(a, confidence=0.9) + True + >>> is_standard_normal(a + 1) + False + """ + ks = stats.kstest(a, 'norm') + 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. diff --git a/tests/test_sklearn.py b/tests/test_sklearn.py index 4fb58ae..2f0c8d9 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,10 +33,47 @@ 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_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. + """ + 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"): + 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(): """ Checks that the distribution of test data (i.e. transformed only) is the @@ -62,7 +99,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 +112,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: @@ -120,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: @@ -164,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: