diff --git a/.travis.yml b/.travis.yml index 75188ca..2b211c2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,7 +12,7 @@ install: - pip install Cython - pip install "pytest>=6.0" "pytest-cov" script: - - ./deploy_test.sh + - ./build_install_test.sh - pytest --version - pytest -c pyproject.toml after_success: diff --git a/CHANGELOG.md b/CHANGELOG.md index 443b9b5..5d54e58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,20 @@ - First functional release ## v2.0.0 (07.12.2020) -- Enhancement: Renamed package cyminiball to comply with [PEP 423](https://www.python.org/dev/peps/pep-0423/) -- Enhancement: Refactored function signature of `compute_max_chord()` -- Enhancement: Added argument `tol` to control numerical validity check if (applies if `details=True`) -- Enhancement: Introduced basic setup for CI tools +- Enhancement: Rename package cyminiball to comply with [PEP 423](https://www.python.org/dev/peps/pep-0423/) +- Enhancement: Refactor function signature of `compute_max_chord()` +- Enhancement: Add argument `tol` to control numerical validity check if (applies if `details=True`) +- Enhancement: Introduce basic setup for CI tools - Enhancement: Enable coverage analysis for Cython code -- Enhancement: Improved coding style and test coverage +- Enhancement: Improve coding style and test coverage - Fix: Issue #1 Performance problem related to `compute(..., details=True)` - Fix: Issue #2 (suppress unnecessary runtime errors) + + +## v2.1.0 (31.03.2021) +- Enhancement: Update documenation +- Enhancement: Add requirements.txt +- Miscellaneous: Add reference to project `miniballcpp` +- Miscellaneous: Add examples/comparison.py +- Fix: Deprecation warnings +- Fix: Animated example for matplotlib>=3.3 \ No newline at end of file diff --git a/README.md b/README.md index fb32329..a244ce1 100644 --- a/README.md +++ b/README.md @@ -12,25 +12,30 @@ [![DeepSource](https://deepsource.io/gh/hirsch-lab/cyminiball.svg/?label=active+issues)](https://deepsource.io/gh/hirsch-lab/cyminiball/?ref=repository-badge) -Cyminiball is a utility to compute the smallest bounding ball of a point cloud in arbitrary dimensions. A Python/Cython binding of the popular [miniball](https://people.inf.ethz.ch/gaertner/subdir/software/miniball.html) utility by Bernd Gärtner. +A Python package to compute the smallest bounding ball of a point cloud in arbitrary dimensions. A Python/Cython binding of the popular [miniball](https://people.inf.ethz.ch/gaertner/subdir/software/miniball.html) utility by Bernd Gärtner. -The code is provided under the LGPLv3 license. +To my knowledge, this is currently the fastest implementation available in Python. For other implementations see: -For an implementation in pure Python, see [`miniball`](https://pypi.org/project/miniball/). `cyminiball` can be used as a drop-in replacement for `miniball`; it runs much faster because it is based on an effcient C++ implementation. +- [`miniballcpp`](https://pypi.org/project/MiniballCpp/) Python binding of the same C++ source ([miniball](https://people.inf.ethz.ch/gaertner/subdir/software/miniball.html)) +- [`miniball`](https://pypi.org/project/miniball/) Pure Python implementation (slow) -### Installation: +## Installation: - pip install cyminiball +The package is available via pip. -### Usage: +```bash +python -m pip install cyminiball +``` + +## Usage: ```python import cyminiball as miniball import numpy as np -d = 2 # Number of dimensions -n = 10000 # Number of points -dt = np.float # Data type +d = 2 # Number of dimensions +n = 10000 # Number of points +dt = np.float64 # Data type points = np.random.randn(n, d) points = points.astype(dt) @@ -62,19 +67,34 @@ C, r2, info = miniball.compute(points, details=True) See [examples/examples.py](https://github.com/hirsch-lab/cyminiball) for further usage examples -### Build +## Build package -To build the package requires +Building the package requires - Python 3.x - Cython - numpy +First, download the project and set up the environment. + ```bash -git clone https://github.com/hirsch-lab/cyminiball.git +git clone "https://github.com/hirsch-lab/cyminiball.git" cd cyminiball -python setup.py build_ext --inplace -python setup.py sdist bdist_wheel -python test/test_all.py -python examples/examples.py +python -m pip install -r "requirements.txt" ``` + +Then build and install the package. Run the tests/examples to verify the package. + +```bash +./build_install.sh +python "tests/test_all.py" +python "examples/examples.py" +``` + +## Performance + +For a comparison with [`miniballcpp`](https://pypi.org/project/MiniballCpp/), run the below command. In my experiments, the Cython-optimized code ran 10-50 times faster, depending on the number of points and point dimensions. + +```bash +python "examples/comparison.py" +``` \ No newline at end of file diff --git a/VERSION b/VERSION index 227cea2..7ec1d6d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.0.0 +2.1.0 diff --git a/deploy.sh b/build_install.sh similarity index 93% rename from deploy.sh rename to build_install.sh index 21aa7a0..f7a5195 100755 --- a/deploy.sh +++ b/build_install.sh @@ -7,6 +7,7 @@ rm -f src/_miniball_wrap.cpp rm -rf build rm -rf dist pip uninstall -y cyminiball +pip cache remove cyminiball # Build export CYMINIBALL_TRACE="0" diff --git a/deploy_test.sh b/build_install_test.sh similarity index 95% rename from deploy_test.sh rename to build_install_test.sh index 22b1a67..a3d5bf4 100755 --- a/deploy_test.sh +++ b/build_install_test.sh @@ -10,6 +10,7 @@ rm -f src/_miniball_wrap.cpp rm -rf build rm -rf dist pip uninstall -y cyminiball +pip cache remove cyminiball # Build export CYMINIBALL_TRACE="1" diff --git a/examples/comparison.py b/examples/comparison.py new file mode 100644 index 0000000..b5e05f2 --- /dev/null +++ b/examples/comparison.py @@ -0,0 +1,85 @@ +""" +Compare different implementations of miniball: + +1. cyminiball: https://pypi.org/project/cyminiball/ +2. miniballcpp: https://pypi.org/project/MiniballCpp/ +3. miniball: https://pypi.org/project/miniball/ + +Since miniball and miniballcpp have the same package name (miniball), they +cannot be used side by side. The former is very slow, hence it is excluded here. +""" + +import timeit +import numpy as np +import miniball # miniballcpp or miniball +import cyminiball +import matplotlib.pyplot as plt + + +################################################################################ +def create_data(n, d, dt): + points = np.random.randn(n, d) + points = points.astype(dt) + return points + + +################################################################################ +def run_timer(statement, context): + timer = timeit.Timer(statement, globals=context) + n_reps, delta = timer.autorange() + return delta/n_reps, n_reps + + +################################################################################ +def measure_performance(d=2, dt=np.float64, n_steps=10): + a_min = 3 + a_max = 7 + ns = np.logspace(a_min, a_max, n_steps).astype(int) + t1 = np.zeros(len(ns)) + t2 = np.zeros(len(ns)) + statement1 = "cyminiball.compute(points)" + statement2 = "miniball.Miniball(points)" + #statement2 = "miniball.get_bounding_ball(points)" + + print("Running...") + + for i,n in enumerate(ns): + points = create_data(n=n, d=d, dt=dt) + context = dict(miniball=miniball, cyminiball=cyminiball, points=points) + delta1, n_reps1 = run_timer(statement=statement1, context=context) + t1[i] = delta1 + delta2, n_reps2 = run_timer(statement=statement2, context=context) + t2[i] = delta2 + print(f"%2d/%2d (n: %{a_max+1}d, d: %d, n_reps: %d, %d)" + % (i+1, len(ns), n, d, n_reps1, n_reps2)) + + ratio = t2/t1 + print("Done!") + print() + print("ratio: %.2f ± %.2f" % (ratio.mean(), ratio.std())) + print("ratios: %s" % ", ".join(map(str, ratio.round(1)))) + return ns, t1, t2 + + +################################################################################ +def plot_results(ns, t1, t2, d, dt): + fig, ax = plt.subplots() + ax.plot(ns, t1*1000, "-o", label="cyminiball") + ax.plot(ns, t2*1000, "-x", label="miniball") + ax.set_xlabel("Number of points") + ax.set_ylabel("Time (ms)") + ax.set_title("Point cloud: d=%d, type=%s" % (d, dt.__name__)) + ax.legend() + ax.grid(True) + ax.set_yscale("log") + ax.set_xscale("log") + plt.show() + + +################################################################################ +if __name__ == "__main__": + d = 2 + dt = np.float64 + n_steps = 10 + ns, t1, t2 = measure_performance(d=d, n_steps=n_steps) + plot_results(ns=ns, t1=t1, t2=t2, d=d, dt=dt) diff --git a/examples/examples.py b/examples/examples.py index 28cd220..7874e69 100644 --- a/examples/examples.py +++ b/examples/examples.py @@ -7,7 +7,7 @@ ################################################################################ def generate_data(n=50): d = 2 # Number of dimensions - dt = np.float # Different data types are supported + dt = np.float64 # Different data types are supported rs = np.random.RandomState(42) points = rs.randn(n, d) points = points.astype(dt) @@ -95,10 +95,12 @@ def update(x): [points[-1, 1], points[-1, 1]]]) return circle, center, line, point + # Info: Blitting seems not to work with OSX backend. + # (Check the backend that is set in .matplotlibrc) from matplotlib.animation import FuncAnimation - FuncAnimation(fig, update, frames=xrange, interval=30, - init_func=init, blit=True) - + anim = FuncAnimation(fig, update, frames=xrange, interval=30, + init_func=init, blit=True) + return anim ################################################################################ def benchmark_with_details(): @@ -135,5 +137,5 @@ def benchmark_with_details(): ################################################################################ if __name__ == "__main__": example_basic() - example_animated() + anim = example_animated() plt.show() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..68be9ba --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +# Install with pip: +# python -m pip install -r requirements.txt +numpy +Cython>=0.29 +pytest-cov # for test-coverage +miniballcpp # in examples/comparison.py diff --git a/src/__init__.py b/src/__init__.py index 53d8d3c..18324bb 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -11,5 +11,8 @@ MiniballValueError, compute, compute_no_checks, - compute_max_chord, - get_bounding_ball) + compute_max_chord) + +# To mimic the interface of other miniball projects +from ._compat import (get_bounding_ball, + Miniball) diff --git a/src/_compat.py b/src/_compat.py new file mode 100644 index 0000000..4781a1a --- /dev/null +++ b/src/_compat.py @@ -0,0 +1,33 @@ +from ._wrap import compute + + +def get_bounding_ball(points): + """An alias for miniball.compute() with the purpose to make the + cyminiball package a drop-in replacement for another miniball project + available on PyPI: https://pypi.org/project/miniball/ + """ + return compute(points, details=False, tol=None) + + +class Miniball: + """Mimic the interface of yet another miniball PyPI project: + https://pypi.org/project/MiniballCpp/ + """ + def __init__(self, points): + _, _, info = compute(points, details=True) + self._info = info + + def center(self): + return self._info["center"] + + def squared_radius(self): + return self._info["radius"]**2 + + def relative_error(self): + return self._info["relative_error"] + + def is_valid(self): + return self._info["is_valid"] + + def get_time(self): + return self._info["elapsed"] diff --git a/src/_miniball_wrap.pyx b/src/_miniball_wrap.pyx index cd4f26b..15e9b9c 100644 --- a/src/_miniball_wrap.pyx +++ b/src/_miniball_wrap.pyx @@ -245,15 +245,6 @@ def compute(points, details=False, tol=None): return compute_no_checks(points, details, tol) -################################################################################ -def get_bounding_ball(points): - """An alias for miniball.compute() with the purpose to make the - cyminiball package a drop-in replacement for another miniball project - available on PyPi: https://pypi.org/project/miniball/ - """ - return compute(points, details=False, tol=None) - - ################################################################################ def compute_max_chord(points, info=None, details=False, tol=None): """Compute the longest chord between the support points of the miniball. diff --git a/tests/test_all.py b/tests/test_all.py index 4d3a5cf..cf6ac42 100644 --- a/tests/test_all.py +++ b/tests/test_all.py @@ -9,7 +9,7 @@ class TestTypes(unittest.TestCase): np.float64: np.float64, np.float128: np.float128, # np.float16: float, # not available - np.float: float + # np.float: float # deprecated } valid_itypes = {int: float, @@ -25,7 +25,7 @@ class TestTypes(unittest.TestCase): # np.bool8: float, # supported, but not tested. # np.bool: float # supported, but not tested. } - invalid_dtypes = [str, np.str, np.complex, np.complex64, + invalid_dtypes = [str, complex, np.complex64, np.complex128, np.complex256, np.float16] iterables = [list, tuple, set] detailed = False @@ -300,6 +300,9 @@ def test_plain(self): self.assertLessEqual(info["d_max"], upper) def test_get_bounding_ball(self): + """ + See miniball._compat.get_bounding_ball() + """ for dt in self.dtypes: with self.subTest(dt=dt): data = self.rs.normal(0, 1, (100, 5)) @@ -309,6 +312,24 @@ def test_get_bounding_ball(self): np.testing.assert_array_equal(C_A, C_B) self.assertEqual(r2_A, r2_B) + def test_miniballcpp_interface(self): + """ + See miniball._compat.Miniball() + """ + for dt in self.dtypes: + with self.subTest(dt=dt): + data = self.rs.normal(0, 1, (100, 5)) + data = data.astype(dt) + C_A, r2_A, info = mb.compute(data, details=True) + M = mb.Miniball(data) + C_B = M.center() + r2_B = M.squared_radius() + np.testing.assert_array_equal(C_A, C_B) + self.assertEqual(r2_A, r2_B) + self.assertEqual(info["relative_error"], M.relative_error()) + self.assertEqual(info["is_valid"], M.is_valid()) + np.testing.assert_almost_equal(info["elapsed"], M.get_time(), 3) + ################################################################################ if __name__ == "__main__":