diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml index 21f2f01..ff1e384 100644 --- a/.github/workflows/pythonpublish.yml +++ b/.github/workflows/pythonpublish.yml @@ -16,11 +16,11 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install setuptools wheel twine + pip install build setuptools wheel twine - name: Build and publish env: TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} run: | - python setup.py sdist bdist_wheel + python -m build twine upload dist/* diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b4ada9b..8825373 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,6 +1,6 @@ name: Unit tests and coverage -on: [push] +on: [push, workflow_dispatch, pull_request] jobs: build: @@ -8,7 +8,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] os: [ubuntu-latest, macos-latest, windows-latest] env: OS: ${{ matrix.os }} @@ -16,20 +16,20 @@ jobs: steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip - pip install pytest pytest-cov - pip install -r requirements.txt - pip install -e . + pip install -e .[testing] - name: Generate coverage report run: | - pytest --cov-report=xml + pytest --cov=kneed --cov-report=xml:coverage1.xml tests/test_sample.py + pip uninstall -y matplotlib + pytest --cov=kneed --cov-report=xml:coverage2.xml tests/test_no_matplotlib.py - name: Upload coverage to Codecov - uses: codecov/codecov-action@v2 + uses: codecov/codecov-action@v3 with: env_vars: OS,PYTHON fail_ci_if_error: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eb8a459..ab8746d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ I have tried to follow the `Kneedle` algorithm as best as I could interpret from but the source code is far from perfect and could benefit from improvements. - Submit a bug report or feature request on [GitHub Issues](https://github.com/arvkevi/kneed/issues). -- Contribute a Jupyter notebook to [notebooks](https://github.com/arvkevi/kneed/tree/master/notebooks). +- Contribute a Jupyter notebook to [notebooks](https://github.com/arvkevi/kneed/tree/main/notebooks). - Documenting applications where `kneed` could be useful. - Code refactors -- the code was refactored in `0.4.0` to be more human-readable. However I think the code could still be greatly improved by breaking the `KneeLocator` class into a collection of methods. This would make the algorithm easier to unittest. @@ -55,18 +55,11 @@ Once forked, use the following steps to get your development environment set up 3. Install dependencies. - Kneed's dependencies are in the `requirements.txt` document at the root of the repository. Open this file and uncomment the dependencies that are for development only. Then install the dependencies with `pip`: + Kneed's main dependencies are in the `requirements.txt` document at the root of the repository, however you will later also need to install testing dependencies. + Install them all with `pip`: ``` - $ pip install -r requirements.txt - ``` - - Note that there are dependencies required for testing, you can simply install them with `pip`. For example to install - the additional dependencies for building the documentation or to run the - test suite, use the `requirements.txt` files in those directories: - - ``` - $ pip install -r tests/requirements.txt + $ pip install -e .[testing] ``` At this point you're ready to get started writing code! diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 1339dce..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,4 +0,0 @@ -include LICENSE -include README.md -include requirements.txt -include tests/test_sample.py diff --git a/README.md b/README.md index ee9ced0..3f861f2 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # kneed Knee-point detection in Python -[![Downloads](https://pepy.tech/badge/kneed)](https://pepy.tech/project/kneed) [![Downloads](https://pepy.tech/badge/kneed/week)](https://pepy.tech/project/kneed) ![Dependents](https://badgen.net/github/dependents-repo/arvkevi/kneed/?icon=github) [![Open in Streamlit](https://static.streamlit.io/badges/streamlit_badge_black_white.svg)](https://share.streamlit.io/arvkevi/ikneed/main/ikneed.py) [![Build Status](https://travis-ci.com/arvkevi/kneed.svg?branch=master)](https://travis-ci.com/arvkevi/kneed) [![codecov](https://codecov.io/gh/arvkevi/kneed/branch/master/graph/badge.svg)](https://codecov.io/gh/arvkevi/kneed)[![DOI](https://zenodo.org/badge/113799037.svg)](https://zenodo.org/badge/latestdoi/113799037) +[![Downloads](https://pepy.tech/badge/kneed)](https://pepy.tech/project/kneed) [![Downloads](https://pepy.tech/badge/kneed/week)](https://pepy.tech/project/kneed) ![Dependents](https://badgen.net/github/dependents-repo/arvkevi/kneed/?icon=github) [![Open in Streamlit](https://static.streamlit.io/badges/streamlit_badge_black_white.svg)](https://share.streamlit.io/arvkevi/ikneed/main/ikneed.py) [![codecov](https://codecov.io/gh/arvkevi/kneed/branch/main/graph/badge.svg)](https://codecov.io/gh/arvkevi/kneed)[![DOI](https://zenodo.org/badge/113799037.svg)](https://zenodo.org/badge/latestdoi/113799037) This repository is an attempt to implement the kneedle algorithm, published [here](https://www1.icsi.berkeley.edu/~barath/papers/kneedle-simplex11.pdf). Given a set of `x` and `y` values, `kneed` will return the knee point of the function. The knee point is the point of maximum curvature. -![](https://raw.githubusercontent.com/arvkevi/kneed/master/images/functions_args_summary.png) +![](https://raw.githubusercontent.com/arvkevi/kneed/main/images/functions_args_summary.png) ## Table of contents - [Installation](#installation) @@ -20,7 +20,7 @@ This repository is an attempt to implement the kneedle algorithm, published [her - [Citation](#citation) ## Installation -`kneed` has been tested with Python 3.5, 3.6, 3.7, 3.8, 3.9, and 3.10. +`kneed` has been tested with Python 3.7, 3.8, 3.9, and 3.10. **anaconda** ```bash @@ -29,13 +29,14 @@ $ conda install -c conda-forge kneed **pip** ```bash -$ pip install kneed +$ pip install kneed # To install only knee-detection algorithm +$ pip install kneed[plot] # To also install plotting functions for quick visualizations ``` **Clone from GitHub** ```bash -$ git clone https://github.com/arvkevi/kneed.git -$ python setup.py install +$ git clone https://github.com/arvkevi/kneed.git && cd kneed +$ pip install -e . ``` ## Usage @@ -85,14 +86,14 @@ The `KneeLocator` class also has two plotting functions for quick visualizations kneedle.plot_knee_normalized() ``` -![](https://raw.githubusercontent.com/arvkevi/kneed/master/images/figure2.knee.png) +![](https://raw.githubusercontent.com/arvkevi/kneed/main/images/figure2.knee.png) ```python # Raw data and knee. kneedle.plot_knee() ``` -![](https://raw.githubusercontent.com/arvkevi/kneed/master/images/figure2.knee.raw.png) +![](https://raw.githubusercontent.com/arvkevi/kneed/main/images/figure2.knee.raw.png) ## Documentation Documentation of the parameters and a full API reference can be found [here](https://kneed.readthedocs.io/). @@ -109,7 +110,7 @@ You can also run your own version -- head over to the [source code for ikneed](h ## Contributing -Contributions are welcome, please refer to [CONTRIBUTING](https://github.com/arvkevi/kneed/blob/master/CONTRIBUTING.md) +Contributions are welcome, please refer to [CONTRIBUTING](https://github.com/arvkevi/kneed/blob/main/CONTRIBUTING.md) to learn more about how to contribute. ## Citation diff --git a/codecov.yml b/codecov.yml deleted file mode 100644 index c314c4c..0000000 --- a/codecov.yml +++ /dev/null @@ -1,24 +0,0 @@ -codecov: - require_ci_to_pass: yes - -coverage: - precision: 2 - round: down - range: "70...100" - -parsers: - gcov: - branch_detection: - conditional: yes - loop: yes - method: no - macro: no - -comment: - layout: "reach,diff,flags,tree" - behavior: default - require_changes: no - -ignore: - - "tests" - diff --git a/docs/conf.py b/docs/conf.py index f55bf39..34c4ab3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -12,17 +12,22 @@ # import os import sys -sys.path.insert(0, os.path.abspath('..')) + +sys.path.insert(0, os.path.abspath("..")) + +import kneed # -- Project information ----------------------------------------------------- -project = 'kneed' -copyright = '2020, Kevin Arvai' -author = 'Kevin Arvai' +project = "kneed" +copyright = "2020, Kevin Arvai" +author = "Kevin Arvai" # The full version, including alpha/beta/rc tags -release = '0.6.0' +release = kneed.__version__ + +version = kneed.__version__ # -- General configuration --------------------------------------------------- @@ -31,19 +36,22 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.coverage', - 'sphinx.ext.napoleon', - 'sphinx_rtd_theme' + "sphinx.ext.autodoc", + "sphinx.ext.autosummary", + "sphinx.ext.coverage", + "sphinx.ext.napoleon", + "sphinx_rtd_theme", ] +pygments_style = "sphinx" + # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # -- Options for HTML output ------------------------------------------------- @@ -56,8 +64,8 @@ # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = [] -autoclass_content = 'both' +autoclass_content = "both" -master_doc = 'index' +master_doc = "index" diff --git a/docs/index.rst b/docs/index.rst index 3050922..ab29831 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -8,9 +8,10 @@ Welcome to kneed's documentation! This is the documentation for the `kneed `__ Python package. Given `x` and `y` arrays, `kneed` attempts to identify the knee/elbow point of a line fit to the data. -The knee/elbow is defined as the point of the line with maximum curvature. For more information about how each of -the parameters affect identification of knee points, check out :ref:`parameters`. For a full reference of the API, -head over to the :ref:`api`. +The knee/elbow is defined as the point of the line with maximum curvature. + +For more information about how each of the parameters affect identification of knee points, check out :ref:`parameters`. +For a full reference of the API, head over to the :ref:`api`. .. toctree:: parameters diff --git a/docs/interactive.rst b/docs/interactive.rst index 05b3490..baf2fe3 100644 --- a/docs/interactive.rst +++ b/docs/interactive.rst @@ -4,10 +4,8 @@ Interactive Streamlit App ========================= An interactive streamlit app was developed to help users explore the effect of tuning the parameters. -There are two sites where you can test out kneed by copy-pasting your own data: -1. https://share.streamlit.io/arvkevi/ikneed/main/ikneed.py -2. https://ikneed.herokuapp.com/ +https://share.streamlit.io/arvkevi/ikneed/main/ikneed.py You can also run your own version -- head over to the source code for ikneed_. diff --git a/docs/parameters.rst b/docs/parameters.rst index 5fa85cf..82621ef 100644 --- a/docs/parameters.rst +++ b/docs/parameters.rst @@ -110,9 +110,9 @@ Any `S`>200 will result in a knee at 482 (0.48, normalized) in the plot above. online ------ -The knee point can be corrected if the parameter online is `True` (default). This mode will step through each element +The knee point can be corrected if the parameter online is `True`. This mode will step through each element in x. -In contrast, if online is False, kneed will run in offline mode and return the first knee point identified. +In contrast, if online is `False` (default), kneed will run in offline mode and return the first knee point identified. When `online=False` the first knee point identified is returned regardless of whether it's the local maxima on the difference curve or the global maxima. So the algorithm stops early. When `online=True`, kneed runs in online mode and "corrects" itself by continuing to diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..eb28b38 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,9 @@ +sphinx==6.2.1 +sphinx_rtd_theme==1.2.2 +sphinxcontrib-applehelp==1.0.4 +sphinxcontrib-devhelp==1.0.2 +sphinxcontrib-htmlhelp==2.0.1 +sphinxcontrib-jquery==4.1 +sphinxcontrib-jsmath==1.0.1 +sphinxcontrib-qthelp==1.0.3 +sphinxcontrib-serializinghtml==1.1.5 diff --git a/kneed/__init__.py b/kneed/__init__.py index 60759b2..8e8d208 100644 --- a/kneed/__init__.py +++ b/kneed/__init__.py @@ -1,5 +1,4 @@ from .data_generator import DataGenerator from .knee_locator import KneeLocator from .shape_detector import find_shape - -from .version import __version__ +from ._version import __version__ diff --git a/kneed/_version.py b/kneed/_version.py new file mode 100644 index 0000000..af46754 --- /dev/null +++ b/kneed/_version.py @@ -0,0 +1 @@ +__version__ = "0.8.5" diff --git a/kneed/knee_locator.py b/kneed/knee_locator.py index 0112746..c05134f 100644 --- a/kneed/knee_locator.py +++ b/kneed/knee_locator.py @@ -1,22 +1,31 @@ import numpy as np from scipy.signal import argrelextrema -import warnings from typing import Tuple, Optional, Iterable VALID_CURVE = ["convex", "concave"] VALID_DIRECTION = ["increasing", "decreasing"] +try: + import matplotlib.pyplot as plt +except ImportError: + _has_matplotlib = False + _matplotlib_not_found_err = ModuleNotFoundError( + "This function needs Matplotlib to be executed. Please run command `pip install kneed[plot]` " + ) +else: + _has_matplotlib = True + class KneeLocator(object): """ Once instantiated, this class attempts to find the point of maximum curvature on a line. The knee is accessible via the `.knee` attribute. - :param x: x values. - :type x: array-like - :param y: y values. - :type y: array-like - :param S: Sensitivity, original paper suggests default of 1.0 + :param x: x values, must be the same length as y. + :type x: 1D array of shape (`number_of_y_values`,) or list + :param y: y values, must be the same length as x. + :type y: 1D array of shape (`number_of_y_values`,) or list + :param S: Sensitivity, the number of minimum number of data points below the local distance maximum before calling a knee. The original paper suggests default of 1.0 :type S: float :param curve: If 'concave', algorithm will detect knees. If 'convex', it will detect elbows. @@ -29,6 +38,93 @@ class KneeLocator(object): :type online: bool :param polynomial_degree: The degree of the fitting polynomial. Only used when interp_method="polynomial". This argument is passed to numpy polyfit `deg` parameter. :type polynomial_degree: int + :ivar x: x values. + :vartype x: array-like + :ivar y: y values. + :vartype y: array-like + :ivar S: Sensitivity, original paper suggests default of 1.0 + :vartype S: integer + :ivar curve: If 'concave', algorithm will detect knees. If 'convex', it + will detect elbows. + :vartype curve: str + :ivar direction: one of {"increasing", "decreasing"} + :vartype direction: str + :ivar interp_method: one of {"interp1d", "polynomial"} + :vartype interp_method: str + :ivar online: kneed will correct old knee points if True, will return first knee if False + :vartype online: str + :ivar polynomial_degree: The degree of the fitting polynomial. Only used when interp_method="polynomial". This argument is passed to numpy polyfit `deg` parameter. + :vartype polynomial_degree: int + :ivar N: The number of `x` values in the + :vartype N: integer + :ivar all_knees: A set containing all the x values of the identified knee points. + :vartype all_knees: set + :ivar all_norm_knees: A set containing all the normalized x values of the identified knee points. + :vartype all_norm_knees: set + :ivar all_knees_y: A list containing all the y values of the identified knee points. + :vartype all_knees_y: list + :ivar all_norm_knees_y: A list containing all the normalized y values of the identified knee points. + :vartype all_norm_knees_y: list + :ivar Ds_y: The y values from the fitted spline. + :vartype Ds_y: numpy array + :ivar x_normalized: The normalized x values. + :vartype x_normalized: numpy array + :ivar y_normalized: The normalized y values. + :vartype y_normalized: numpy array + :ivar x_difference: The x values of the difference curve. + :vartype x_difference: numpy array + :ivar y_difference: The y values of the difference curve. + :vartype y_difference: numpy array + :ivar maxima_indices: The indices of each of the maxima on the difference curve. + :vartype maxima_indices: numpy array + :ivar maxima_indices: The indices of each of the maxima on the difference curve. + :vartype maxima_indices: numpy array + :ivar x_difference_maxima: The x values from the difference curve where the local maxima are located. + :vartype x_difference_maxima: numpy array + :ivar y_difference_maxima: The y values from the difference curve where the local maxima are located. + :vartype y_difference_maxima: numpy array + :ivar minima_indices: The indices of each of the minima on the difference curve. + :vartype minima_indices: numpy array + :ivar minima_indices: The indices of each of the minima on the difference curve. + :vartype maxima_indices: numpy array + :ivar x_difference_minima: The x values from the difference curve where the local minima are located. + :vartype x_difference_minima: numpy array + :ivar y_difference_minima: The y values from the difference curve where the local minima are located. + :vartype y_difference_minima: numpy array + :ivar Tmx: The y values that correspond to the thresholds on the difference curve for determining the knee point. + :vartype Tmx: numpy array + :ivar knee: The x value of the knee point. None if no knee/elbow was detected. + :vartype knee: float + :ivar knee_y: The y value of the knee point. None if no knee/elbow was detected + :vartype knee_y: float + :ivar norm_knee: The normalized x value of the knee point. None if no knee/elbow was detected + :vartype norm_knee: float + :ivar norm_knee_y: The normalized y value of the knee point. None if no knee/elbow was detected + :vartype norm_knee_y: float + :ivar all_knees: The x values of all the identified knee points. + :vartype all_knees: set + :ivar all_knees_y: The y values of all the identified knee points. + :vartype all_knees: set + :ivar all_norm_knees: The normalized x values of all the identified knee points. + :vartype all_norm_knees: set + :ivar all_norm_knees_y: The normalized y values of all the identified knee points. + :vartype all_norm_knees: set + :ivar elbow: The x value of the elbow point (elbow and knee are interchangeable). None if no knee/elbow was detected + :vartype elbow: float + :ivar elbow_y: The y value of the knee point (elbow and knee are interchangeable). None if no knee/elbow was detected + :vartype elbow_y: float + :ivar norm_elbow: The normalized x value of the knee point (elbow and knee are interchangeable). None if no knee/elbow was detected + :vartype norm_knee: float + :ivar norm_elbow_y: The normalized y value of the knee point (elbow and knee are interchangeable). None if no knee/elbow was detected + :vartype norm_elbow_y: float + :ivar all_elbows: The x values of all the identified knee points (elbow and knee are interchangeable). + :vartype all_elbows: set + :ivar all_elbows_y: The y values of all the identified knee points (elbow and knee are interchangeable). + :vartype all_elbows: set + :ivar all_norm_elbows: The normalized x values of all the identified knee points (elbow and knee are interchangeable). + :vartype all_norm_elbows: set + :ivar all_norm_elbows_y: The normalized y values of all the identified knee points (elbow and knee are interchangeable). + :vartype all_norm_elbows: set """ def __init__( @@ -42,95 +138,6 @@ def __init__( online: bool = False, polynomial_degree: int = 7, ): - """ - :ivar x: x values. - :vartype x: array-like - :ivar y: y values. - :vartype y: array-like - :ivar S: Sensitivity, original paper suggests default of 1.0 - :vartype S: integer - :ivar curve: If 'concave', algorithm will detect knees. If 'convex', it - will detect elbows. - :vartype curve: str - :ivar direction: one of {"increasing", "decreasing"} - :vartype direction: str - :ivar interp_method: one of {"interp1d", "polynomial"} - :vartype interp_method: str - :ivar online: kneed will correct old knee points if True, will return first knee if False - :vartype online: str - :ivar polynomial_degree: The degree of the fitting polynomial. Only used when interp_method="polynomial". This argument is passed to numpy polyfit `deg` parameter. - :vartype polynomial_degree: int - :ivar N: The number of `x` values in the - :vartype N: integer - :ivar all_knees: A set containing all the x values of the identified knee points. - :vartype all_knees: set - :ivar all_norm_knees: A set containing all the normalized x values of the identified knee points. - :vartype all_norm_knees: set - :ivar all_knees_y: A list containing all the y values of the identified knee points. - :vartype all_knees_y: list - :ivar all_norm_knees_y: A list containing all the normalized y values of the identified knee points. - :vartype all_norm_knees_y: list - :ivar Ds_y: The y values from the fitted spline. - :vartype Ds_y: numpy array - :ivar x_normalized: The normalized x values. - :vartype x_normalized: numpy array - :ivar y_normalized: The normalized y values. - :vartype y_normalized: numpy array - :ivar x_difference: The x values of the difference curve. - :vartype x_difference: numpy array - :ivar y_difference: The y values of the difference curve. - :vartype y_difference: numpy array - :ivar maxima_indices: The indices of each of the maxima on the difference curve. - :vartype maxima_indices: numpy array - :ivar maxima_indices: The indices of each of the maxima on the difference curve. - :vartype maxima_indices: numpy array - :ivar x_difference_maxima: The x values from the difference curve where the local maxima are located. - :vartype x_difference_maxima: numpy array - :ivar y_difference_maxima: The y values from the difference curve where the local maxima are located. - :vartype y_difference_maxima: numpy array - :ivar minima_indices: The indices of each of the minima on the difference curve. - :vartype minima_indices: numpy array - :ivar minima_indices: The indices of each of the minima on the difference curve. - :vartype maxima_indices: numpy array - :ivar x_difference_minima: The x values from the difference curve where the local minima are located. - :vartype x_difference_minima: numpy array - :ivar y_difference_minima: The y values from the difference curve where the local minima are located. - :vartype y_difference_minima: numpy array - :ivar Tmx: The y values that correspond to the thresholds on the difference curve for determining the knee point. - :vartype Tmx: numpy array - :ivar knee: The x value of the knee point. - :vartype knee: float - :ivar knee_y: The y value of the knee point. - :vartype knee_y: float - :ivar norm_knee: The normalized x value of the knee point. - :vartype norm_knee: float - :ivar norm_knee_y: The normalized y value of the knee point. - :vartype norm_knee_y: float - :ivar all_knees: The x values of all the identified knee points. - :vartype all_knees: set - :ivar all_knees_y: The y values of all the identified knee points. - :vartype all_knees: set - :ivar all_norm_knees: The normalized x values of all the identified knee points. - :vartype all_norm_knees: set - :ivar all_norm_knees_y: The normalized y values of all the identified knee points. - :vartype all_norm_knees: set - :ivar elbow: The x value of the elbow point (elbow and knee are interchangeable). - :vartype elbow: float - :ivar elbow_y: The y value of the knee point (elbow and knee are interchangeable). - :vartype elbow_y: float - :ivar norm_elbow: The normalized x value of the knee point (elbow and knee are interchangeable). - :vartype norm_knee: float - :ivar norm_elbow_y: The normalized y value of the knee point (elbow and knee are interchangeable). - :vartype norm_elbow_y: float - :ivar all_elbows: The x values of all the identified knee points (elbow and knee are interchangeable). - :vartype all_elbows: set - :ivar all_elbows_y: The y values of all the identified knee points (elbow and knee are interchangeable). - :vartype all_elbows: set - :ivar all_norm_elbows: The normalized x values of all the identified knee points (elbow and knee are interchangeable). - :vartype all_norm_elbows: set - :ivar all_norm_elbowss_y: The normalized y values of all the identified knee points (elbow and knee are interchangeable). - :vartype all_norm_elbows: set - """ # Step 0: Raw Input self.x = np.array(x) self.y = np.array(y) @@ -229,16 +236,15 @@ def transform_y(y: Iterable[float], direction: str, curve: str) -> float: return y - def find_knee(self,): + def find_knee( + self, + ): """This function is called when KneeLocator is instantiated. It identifies the knee value and sets the instance attributes.""" if not self.maxima_indices.size: - warnings.warn( - "No local maxima found in the difference curve\n" - "The line is probably not polynomial, try plotting\n" - "the difference curve with plt.plot(knee.x_difference, knee.y_difference)\n" - "Also check that you aren't mistakenly setting the curve argument", - RuntimeWarning, - ) + # No local maxima found in the difference curve + # The line is probably not polynomial, try plotting + # the difference curve with plt.plot(knee.x_difference, knee.y_difference) + # Also check that you aren't mistakenly setting the curve argument return None, None # placeholder for which threshold region i is located in. maxima_threshold_index = 0 @@ -253,7 +259,7 @@ def find_knee(self,): j = i + 1 # reached the end of the curve - if x == 1.0: + if i == (len(self.x_difference) - 1): break # if we're at a local max, increment the maxima threshold index and continue @@ -299,25 +305,42 @@ def find_knee(self,): return knee, norm_knee if self.all_knees == set(): - warnings.warn("No knee/elbow found") + # No knee was found return None, None return knee, norm_knee - def plot_knee_normalized(self, figsize: Optional[Tuple[int, int]] = None): + def plot_knee_normalized( + self, + figsize: Optional[Tuple[int, int]] = None, + title: str = "Normalized Knee Point", + xlabel: Optional[str] = None, + ylabel: Optional[str] = None, + ): """Plot the normalized curve, the difference curve (x_difference, y_normalized) and the knee, if it exists. :param figsize: Optional[Tuple[int, int] The figure size of the plot. Example (12, 8) + :param title: str + Title of the visualization, defaults to "Normalized Knee Point" + :param xlabel: Optional[str] + X-axis label + :param ylabel: Optional[str] + y-axis label :return: NoReturn """ - import matplotlib.pyplot as plt + if not _has_matplotlib: + raise _matplotlib_not_found_err if figsize is None: figsize = (6, 6) plt.figure(figsize=figsize) - plt.title("Normalized Knee Point") + plt.title(title) + if xlabel: + plt.xlabel(xlabel) + if ylabel: + plt.ylabel(ylabel) plt.plot(self.x_normalized, self.y_normalized, "b", label="normalized curve") plt.plot(self.x_difference, self.y_difference, "r", label="difference curve") plt.xticks( @@ -336,21 +359,38 @@ def plot_knee_normalized(self, figsize: Optional[Tuple[int, int]] = None): ) plt.legend(loc="best") - def plot_knee(self, figsize: Optional[Tuple[int, int]] = None): + def plot_knee( + self, + figsize: Optional[Tuple[int, int]] = None, + title: str = "Knee Point", + xlabel: Optional[str] = None, + ylabel: Optional[str] = None, + ): """ Plot the curve and the knee, if it exists :param figsize: Optional[Tuple[int, int] The figure size of the plot. Example (12, 8) + :param title: str + Title of the visualization, defaults to "Knee Point" + :param xlabel: Optional[str] + X-axis label + :param ylabel: Optional[str] + y-axis label :return: NoReturn """ - import matplotlib.pyplot as plt + if not _has_matplotlib: + raise _matplotlib_not_found_err if figsize is None: figsize = (6, 6) plt.figure(figsize=figsize) - plt.title("Knee Point") + plt.title(title) + if xlabel: + plt.xlabel(xlabel) + if ylabel: + plt.ylabel(ylabel) plt.plot(self.x, self.y, "b", label="data") plt.vlines( self.knee, plt.ylim()[0], plt.ylim()[1], linestyles="--", label="knee/elbow" diff --git a/kneed/shape_detector.py b/kneed/shape_detector.py index 9bb28e8..35fc047 100644 --- a/kneed/shape_detector.py +++ b/kneed/shape_detector.py @@ -12,9 +12,9 @@ def find_shape(x, y): x1, x2 = int(len(x) * 0.2), int(len(x) * 0.8) q = np.mean(y[x1:x2]) - np.mean(x[x1:x2] * p[0] + p[1]) if p[0] > 0 and q > 0: - return 'increasing', 'concave' + return "increasing", "concave" if p[0] > 0 and q <= 0: - return 'increasing', 'convex' + return "increasing", "convex" if p[0] <= 0 and q > 0: - return 'decreasing', 'concave' - return 'decreasing', 'convex' \ No newline at end of file + return "decreasing", "concave" + return "decreasing", "convex" diff --git a/kneed/version.py b/kneed/version.py deleted file mode 100644 index 49e0fc1..0000000 --- a/kneed/version.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = "0.7.0" diff --git a/notebooks/decreasing_function_walkthrough.ipynb b/notebooks/decreasing_function_walkthrough.ipynb index 0799b0b..c770956 100644 --- a/notebooks/decreasing_function_walkthrough.ipynb +++ b/notebooks/decreasing_function_walkthrough.ipynb @@ -235,7 +235,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.6.7" + "version": "3.7.13" } }, "nbformat": 4, diff --git a/notebooks/kneedle_algorithm.ipynb b/notebooks/kneedle_algorithm.ipynb index 6ddda75..9fff308 100644 --- a/notebooks/kneedle_algorithm.ipynb +++ b/notebooks/kneedle_algorithm.ipynb @@ -468,7 +468,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.6.7" + "version": "3.7.13" }, "pycharm": { "stem_cell": { @@ -476,7 +476,7 @@ "source": [], "metadata": { "collapsed": false - } + } } } }, diff --git a/notebooks/walkthrough.ipynb b/notebooks/walkthrough.ipynb index 392a3ad..e00cd2a 100644 --- a/notebooks/walkthrough.ipynb +++ b/notebooks/walkthrough.ipynb @@ -568,7 +568,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.6.8" + "version": "3.7.13" }, "pycharm": { "stem_cell": { @@ -576,7 +576,7 @@ "source": [], "metadata": { "collapsed": false - } + } } } }, diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3526fcd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,78 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "kneed" +dynamic = ["version"] +description = "Knee-point detection in Python" +readme = "README.md" +license-files = { paths = ["LICENSE"] } +requires-python = ">=3.5" +authors = [ + { name = "Kevin Arvai", email = "arvkevi@gmail.com" }, +] +keywords = [ + "knee-detection", + "system", + "elbow-method", +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Science/Research", + "Programming Language :: Python :: 3", + "Topic :: Scientific/Engineering :: Information Analysis", +] +dependencies = [ + "numpy>=1.14.2", + "scipy>=1.0.0", +] + +[project.optional-dependencies] +plot = [ + "matplotlib>=2.2.5", +] +testing = [ + "matplotlib>=2.2.5", + "pytest-cov>=3.0.0", + "pytest>=5.0.1", +] + +[project.urls] +Homepage = "https://github.com/arvkevi/kneed" +Documentation = "https://kneed.readthedocs.io/en/latest/" + +[tool.hatch.version] +path = "kneed/_version.py" + +[tool.hatch.build.targets.sdist] +include = [ + "/kneed", + "/tests", + "/LICENSE", + "/README.md", +] + +[tool.hatch.envs.test_no_mpl] +dependencies = [ + "pytest>=5.0.1", + "pytest-cov>=3.0.0", + "scipy", + "numpy", +] +skip-install = false + +[tool.hatch.envs.test] +template = "test_no_mpl" +extra-dependencies = [ + "matplotlib>=2.2.5", +] +skip-install = false + +[tool.hatch.envs.test_no_mpl.scripts] +run-coverage = "pytest --cov=kneed --cov-report=xml:coverage2.xml tests/test_no_matplotlib.py" +run = "run-coverage --no-cov" + +[tool.hatch.envs.test.scripts] +run-coverage = "pytest --cov=kneed --cov-report=xml:coverage1.xml tests/test_sample.py" +run = "run-coverage --no-cov" diff --git a/readthedocs.yaml b/readthedocs.yaml new file mode 100644 index 0000000..68c4590 --- /dev/null +++ b/readthedocs.yaml @@ -0,0 +1,24 @@ +# .readthedocs.yaml +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the version of Python and other tools you might need +build: + os: ubuntu-22.04 + tools: + python: "3.11" + +# Build documentation in the docs/ directory with Sphinx +sphinx: + configuration: docs/conf.py + +# We recommend specifying your dependencies to enable reproducible builds: +# https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html +python: + install: + - requirements: docs/requirements.txt + - method: pip + path: . diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 79dec40..0000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -matplotlib -numpy>=1.14.2 -scipy diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index cb4a338..0000000 --- a/setup.cfg +++ /dev/null @@ -1,5 +0,0 @@ -[bdist_wheel] -universal=1 - -[metadata] -description-file=README.md \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index a4a3064..0000000 --- a/setup.py +++ /dev/null @@ -1,48 +0,0 @@ -from setuptools import setup, find_packages -from codecs import open as copen -from os import path - - -here = path.abspath(path.dirname(__file__)) - -# Get the long description from the README file -with copen(path.join(here, "README.md"), encoding="utf-8") as f: - long_description = f.read() - -# get the dependencies and installs -with copen(path.join(here, "requirements.txt"), encoding="utf-8") as f: - all_reqs = f.read().split("\n") - -install_requires = [x.strip() for x in all_reqs if "git+" not in x] -dependency_links = [ - x.strip().replace("git+", "") for x in all_reqs if x.startswith("git+") -] - -version = {} -with open("kneed/version.py") as fp: - exec(fp.read(), version) - -setup( - name="kneed", - version=version["__version__"], - description="Knee-point detection in Python", - long_description=long_description, - long_description_content_type="text/markdown", - url="https://github.com/arvkevi/kneed", - download_url="https://github.com/arvkevi/kneed/tarball/" + version["__version__"], - license="BSD", - classifiers=[ - "Development Status :: 3 - Alpha", - "Intended Audience :: Science/Research", - "Topic :: Scientific/Engineering :: Information Analysis", - "Programming Language :: Python :: 3", - ], - keywords="knee-detection system", - packages=find_packages(exclude=["docs", "tests*"]), - include_package_data=True, - author="Kevin Arvai", - install_requires=install_requires, - tests_requires=["pytest"], - dependency_links=dependency_links, - author_email="arvkevi@gmail.com", -) diff --git a/tests/requirements.txt b/tests/requirements.txt deleted file mode 100644 index 9769b00..0000000 --- a/tests/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -pytest>=5.0.1 -pytest-coverage \ No newline at end of file diff --git a/tests/test_no_matplotlib.py b/tests/test_no_matplotlib.py new file mode 100644 index 0000000..9316fe8 --- /dev/null +++ b/tests/test_no_matplotlib.py @@ -0,0 +1,19 @@ +import pytest +from kneed.data_generator import DataGenerator as dg +from kneed.knee_locator import KneeLocator + + +def test_plot_knee_normalized(): + """Test that error is raised when matplotlib is not installed""" + with pytest.raises(ModuleNotFoundError): + x, y = dg.figure2() + kl = KneeLocator(x, y, S=1.0, curve="concave", interp_method="interp1d") + kl.plot_knee_normalized() + + +def test_plot_knee(): + """Test that error is raised when matplotlib is not installed""" + with pytest.raises(ModuleNotFoundError): + x, y = dg.figure2() + kl = KneeLocator(x, y, S=1.0, curve="concave", interp_method="interp1d") + kl.plot_knee() diff --git a/tests/test_sample.py b/tests/test_sample.py index 69f3b0d..e47ef11 100644 --- a/tests/test_sample.py +++ b/tests/test_sample.py @@ -290,11 +290,12 @@ def test_interp_method(): def test_x_equals_y(): - """Test that a runtime warning is raised when no maxima are found""" + """Test that knee is None when no maxima are found""" x = range(10) y = [1] * len(x) - with pytest.warns(RuntimeWarning): - kl = KneeLocator(x, y) + kl = KneeLocator(x, y) + assert kl.knee is None + def test_plot_knee_normalized(): @@ -522,9 +523,16 @@ def test_logistic(): 98.0, ] ) - kl = KneeLocator(x, y, curve="convex", direction="increasing", online=True,) + kl = KneeLocator( + x, + y, + curve="convex", + direction="increasing", + online=True, + ) assert kl.knee == 73 + def test_valid_curve_direction(): """Test that arguments to curve and direction are valid""" with pytest.raises(ValueError): @@ -538,17 +546,17 @@ def test_find_shape(): """Test that find_shape can detect the right shape of curve line""" x, y = dg.concave_increasing() direction, curve = find_shape(x, y) - assert direction == 'increasing' - assert curve == 'concave' + assert direction == "increasing" + assert curve == "concave" x, y = dg.concave_decreasing() direction, curve = find_shape(x, y) - assert direction == 'decreasing' - assert curve == 'concave' + assert direction == "decreasing" + assert curve == "concave" x, y = dg.convex_decreasing() direction, curve = find_shape(x, y) - assert direction == 'decreasing' - assert curve == 'convex' + assert direction == "decreasing" + assert curve == "convex" x, y = dg.convex_increasing() direction, curve = find_shape(x, y) - assert direction == 'increasing' - assert curve == 'convex' + assert direction == "increasing" + assert curve == "convex"