Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
3 changes: 1 addition & 2 deletions docs/guide/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions docs/guide/tda_workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
```
Expand Down
7 changes: 7 additions & 0 deletions docs/reference/ellcloud.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
74 changes: 74 additions & 0 deletions src/ellphi/ellcloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle empty clouds in distance_matrix

Calling distance_matrix() on an empty cloud can return the wrong shape because pdist_tangency() yields an empty condensed vector and squareform interprets length-0 input as a 1x1 matrix. This violates the method’s (n, n) contract when n == 0 and can break downstream code expecting a true empty precomputed-distance matrix; add an explicit empty-cloud guard before squareform.

Useful? React with 👍 / 👎.

self.pdist_tangency(
parallel=parallel,
n_jobs=n_jobs,
backend=backend,
)
)

@classmethod
def from_point_cloud(
cls: type[EllipseCloud],
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions src/ellphi/ellcloud.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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: ...

Expand Down
3 changes: 2 additions & 1 deletion src/ellphi/visualization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
89 changes: 89 additions & 0 deletions tests/test_ellcloud.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -178,13 +179,101 @@ 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)
direct = pdist_tangency(cloud, backend="python", parallel=False)
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)
Expand Down
16 changes: 16 additions & 0 deletions tests/test_visualization.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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))
Loading