diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index bc92738..dbb1a5e 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,16 @@ ## Unreleased +### Added + +- Added `EllipseCloud.from_cov(...)` as the official constructor for + precomputed centres/covariances, and `EllipseCloud.distance_matrix()` as a + square-matrix convenience wrapper around `pdist_tangency`. + +### Changed + +- `ellphi.visualization.ellipse_patch(...)` now preserves explicit + `facecolor` / `fc` arguments while keeping hollow ellipses as the default. + ### CI - Added Windows wheel build workflow (`win_amd64`, Python 3.10–3.12). diff --git a/docs/guide/quickstart.md b/docs/guide/quickstart.md index 8e0fa86..04e0a59 100644 --- a/docs/guide/quickstart.md +++ b/docs/guide/quickstart.md @@ -41,8 +41,7 @@ print(dists.shape) # (N*(N-1)//2,) Convert to a square matrix when needed: ```python -from scipy.spatial.distance import squareform -D = squareform(dists) +D = cloud.distance_matrix() ``` ## Visualise diff --git a/docs/guide/tda_workflow.md b/docs/guide/tda_workflow.md index 308ee96..904d4cb 100644 --- a/docs/guide/tda_workflow.md +++ b/docs/guide/tda_workflow.md @@ -43,11 +43,10 @@ pd.histogram(1).plot() # H₁ barcode ### Persistence diagram with Ripser ```python -from scipy.spatial.distance import squareform from ripser import ripser from persim import plot_diagrams -D = squareform(dists) +D = cloud.distance_matrix() result = ripser(D, metric="precomputed", maxdim=1) plot_diagrams(result["dgms"]) ``` diff --git a/docs/reference/ellcloud.md b/docs/reference/ellcloud.md index cee9ade..f06ade4 100644 --- a/docs/reference/ellcloud.md +++ b/docs/reference/ellcloud.md @@ -9,4 +9,11 @@ from ellphi import ellipse_cloud cloud = ellipse_cloud(X, k=5) ``` +When centres and covariance matrices are already available, prefer the direct +constructor: + +```python +cloud = EllipseCloud.from_cov(centers, covs) +``` + ::: ellphi.ellcloud diff --git a/src/ellphi/ellcloud.py b/src/ellphi/ellcloud.py index e532bc4..01df549 100644 --- a/src/ellphi/ellcloud.py +++ b/src/ellphi/ellcloud.py @@ -179,6 +179,39 @@ def pdist_tangency( backend=backend, ) + def distance_matrix( + self, + *, + parallel: bool = True, + n_jobs: int | None = -1, + backend: str = "auto", + ) -> numpy.ndarray: + """Computes the square pairwise tangency distance matrix. + + This is a convenience wrapper around `pdist_tangency` that returns the + full square matrix using the same layout as + `scipy.spatial.distance.squareform`. + + Args: + parallel: If `True`, the computation is performed in parallel. + n_jobs: The number of jobs to run in parallel. See + `ellphi.solver.pdist_tangency` for more details. + backend: The backend to use for the computation. Can be one of + "auto", "python", or "cpp". + + Returns: + A square matrix of tangency distances with shape `(n, n)`. + """ + if self.n == 0: + return numpy.empty((0, 0), dtype=float) + return squareform( + self.pdist_tangency( + parallel=parallel, + n_jobs=n_jobs, + backend=backend, + ) + ) + @classmethod def from_point_cloud( cls: type[EllipseCloud], @@ -217,6 +250,47 @@ def from_point_cloud( ellcloud.rescale(method=rescaling) return ellcloud + @classmethod + def from_cov( + cls: type[EllipseCloud], + X: numpy.ndarray, + cov: numpy.ndarray, + *, + rescaling: str = "none", + ) -> EllipseCloud: + """Creates an `EllipseCloud` from centers and covariance matrices. + + This class method is the recommended entry point when ellipsoid + covariances are already known and no k-nearest-neighbour structure is + involved. + + Args: + X: An array of centers with shape `(n, d)` or a single center with + shape `(d,)`. + cov: An array of covariance matrices with shape `(n, d, d)` or a + single covariance matrix with shape `(d, d)`. + rescaling: The method to use for rescaling the ellipses. Can be + one of "none", "median", or "average". + + Returns: + An `EllipseCloud` object created from the provided centers and + covariances. + """ + centers = numpy.array(X, dtype=float, copy=True) + covariances = numpy.array(cov, dtype=float, copy=True) + + if centers.ndim == 1: + centers = centers[numpy.newaxis, :] + if covariances.ndim == 2: + covariances = covariances[numpy.newaxis, :, :] + + coefs = coef_from_cov(centers, covariances) + nbd = numpy.empty((centers.shape[0], 0), dtype=int) + ellcloud = cls(coefs, centers, covariances, k=0, nbd=nbd) + if rescaling != "none": + ellcloud.rescale(method=rescaling) + return ellcloud + @classmethod def from_local_cov( cls: type[EllipseCloud], X: numpy.ndarray, *, k: int = 5 diff --git a/src/ellphi/ellcloud.pyi b/src/ellphi/ellcloud.pyi index 9003e22..108c98d 100644 --- a/src/ellphi/ellcloud.pyi +++ b/src/ellphi/ellcloud.pyi @@ -32,6 +32,9 @@ class EllipseCloud: def pdist_tangency( self, *, parallel: bool = True, n_jobs: int | None = -1, backend: str = "auto" ) -> FloatArray: ... + def distance_matrix( + self, *, parallel: bool = True, n_jobs: int | None = -1, backend: str = "auto" + ) -> FloatArray: ... @classmethod def from_point_cloud( cls, @@ -42,6 +45,14 @@ class EllipseCloud: **kwgs: Any, ) -> EllipseCloud: ... @classmethod + def from_cov( + cls, + X: FloatArray, + cov: FloatArray, + *, + rescaling: str = "none", + ) -> EllipseCloud: ... + @classmethod def from_local_cov(cls, X: FloatArray, *, k: int = 5) -> EllipseCloud: ... def rescale(self, *, method: str = "median") -> float: ... diff --git a/src/ellphi/visualization.py b/src/ellphi/visualization.py index 7ae67d1..5f4f83c 100644 --- a/src/ellphi/visualization.py +++ b/src/ellphi/visualization.py @@ -39,12 +39,13 @@ def ellipse_patch(X, r_major=1, r_minor=1, theta=0, *, cov=None, scale=1, **kwgs """ if cov is not None: r_major, r_minor, theta = axes_from_cov(cov) + if "facecolor" not in kwgs and "fc" not in kwgs: + kwgs["facecolor"] = "none" ellipse = plt.matplotlib.patches.Ellipse( X, width=2 * r_major * scale, height=2 * r_minor * scale, angle=numpy.degrees(theta), - facecolor="none", **kwgs, ) return ellipse diff --git a/tests/test_ellcloud.py b/tests/test_ellcloud.py index 41cea05..0a3f5a8 100644 --- a/tests/test_ellcloud.py +++ b/tests/test_ellcloud.py @@ -1,6 +1,7 @@ import numpy as np import pytest import matplotlib.pyplot as plt +from scipy.spatial.distance import squareform from ellphi.ellcloud import EllipseCloud from ellphi.geometry import coef_from_axes, coef_from_cov @@ -178,6 +179,55 @@ def test_plot_creates_axes_and_patches(): plt.close(ax.figure) +def test_from_cov_accepts_single_2d_sample(): + center = np.array([1.0, 2.0]) + cov = np.array([[2.0, 0.1], [0.1, 1.5]]) + + cloud = EllipseCloud.from_cov(center, cov) + + assert cloud.k == 0 + assert cloud.n == 1 + assert cloud.nbd.shape == (1, 0) + np.testing.assert_allclose(cloud.mean, center[np.newaxis, :]) + np.testing.assert_allclose(cloud.cov, cov[np.newaxis, :, :]) + np.testing.assert_allclose(cloud.coef, coef_from_cov(center, cov)) + + +def test_from_cov_matches_manual_constructor_3d(rng): + expected = random_cloud(rng, n_ellipses=4, dim=3) + + cloud = EllipseCloud.from_cov(expected.mean, expected.cov) + + assert cloud.k == 0 + assert cloud.nbd.shape == (expected.n, 0) + np.testing.assert_allclose(cloud.mean, expected.mean) + np.testing.assert_allclose(cloud.cov, expected.cov) + np.testing.assert_allclose(cloud.coef, expected.coef) + + +@pytest.mark.parametrize("method", ["median", "average"]) +def test_from_cov_rescaling_matches_manual_rescale(rng, method): + original = random_cloud(rng, n_ellipses=5) + manual = EllipseCloud( + coef=original.coef.copy(), + mean=original.mean.copy(), + cov=original.cov.copy(), + k=0, + nbd=np.empty((original.n, 0), dtype=int), + ) + manual.rescale(method=method) + + cloud = EllipseCloud.from_cov( + original.mean.copy(), + original.cov.copy(), + rescaling=method, + ) + + np.testing.assert_allclose(cloud.mean, manual.mean) + np.testing.assert_allclose(cloud.cov, manual.cov) + np.testing.assert_allclose(cloud.coef, manual.coef) + + def test_pdist_tangency_wrapper_matches_solver(rng): cloud = random_cloud(rng, n_ellipses=4) wrapper = cloud.pdist_tangency(backend="python", parallel=False) @@ -185,6 +235,45 @@ def test_pdist_tangency_wrapper_matches_solver(rng): np.testing.assert_allclose(wrapper, direct) +def test_distance_matrix_matches_squareform(rng): + cloud = random_cloud(rng, n_ellipses=4) + + matrix = cloud.distance_matrix(backend="python", parallel=False) + expected = squareform(cloud.pdist_tangency(backend="python", parallel=False)) + + assert matrix.shape == (cloud.n, cloud.n) + np.testing.assert_allclose(matrix, expected) + + +def test_distance_matrix_empty_cloud_returns_empty_square(): + cloud = EllipseCloud( + coef=np.empty((0, 6), dtype=float), + mean=np.empty((0, 2), dtype=float), + cov=np.empty((0, 2, 2), dtype=float), + k=0, + nbd=np.empty((0, 0), dtype=int), + ) + + matrix = cloud.distance_matrix() + + assert matrix.shape == (0, 0) + + +def test_from_cov_rescaling_does_not_mutate_inputs(rng): + original = random_cloud(rng, n_ellipses=5) + means = original.mean.copy() + covs = original.cov.copy() + means_before = means.copy() + covs_before = covs.copy() + + cloud = EllipseCloud.from_cov(means, covs, rescaling="median") + + np.testing.assert_allclose(means, means_before) + np.testing.assert_allclose(covs, covs_before) + assert not np.shares_memory(cloud.mean, means) + assert not np.shares_memory(cloud.cov, covs) + + def test_str_method(): X = np.array([[0.0, 0.0], [1.0, 1.0], [2.0, 0.0]]) ellcloud = EllipseCloud.from_local_cov(X, k=3) diff --git a/tests/test_visualization.py b/tests/test_visualization.py index b9a1641..09ef1f5 100644 --- a/tests/test_visualization.py +++ b/tests/test_visualization.py @@ -1,6 +1,7 @@ """Tests for the ellphi visualization module.""" import numpy as np +from matplotlib.colors import to_rgba from matplotlib.patches import Ellipse from ellphi.visualization import ellipse_patch @@ -38,3 +39,18 @@ def test_ellipse_patch_direct_params(): assert np.isclose(patch.width, 2 * r_major) assert np.isclose(patch.height, 2 * r_minor) assert np.isclose(patch.angle, np.degrees(theta)) + assert np.allclose(patch.get_facecolor(), to_rgba("none")) + + +def test_ellipse_patch_respects_explicit_facecolor(): + """Explicit facecolor/fill arguments should not be overridden.""" + patch = ellipse_patch( + [0.0, 0.0], + r_major=1.5, + r_minor=0.5, + theta=0.0, + facecolor="steelblue", + alpha=0.4, + ) + + assert np.allclose(patch.get_facecolor(), to_rgba("steelblue", alpha=0.4))