Skip to content

Visualization

Plotting helpers for covariance ellipse fields. matplotlib is an optional dependency: pip install 'psyphy[viz]'.

See the plotting guide for worked examples.

ellipses

ellipses

Matplotlib drawing for fields of 2-D covariance ellipses.

matplotlib is an optional dependency (pip install psyphy[viz]) and is imported inside the functions here, so importing psyphy never pulls it in. The geometry these functions draw lives in :mod:psyphy.viz.geometry and needs no plotting backend at all.

Functions:

Name Description
plot_ellipses

Draw one or more fields of 2-D covariance ellipses.

plot_ellipses

plot_ellipses(centers: ndarray, covs: Any, *, ax: Any = None, scale: float | str = 1.0, colors: Any = None, labels: Any = None, linestyles: Any = None, linewidths: Any = None, alpha: Any = None, show_centers: bool = False, skip_non_pd: bool = True, return_scale: bool = False, n_points: int = 100) -> Any

Draw one or more fields of 2-D covariance ellipses.

Parameters:

Name Type Description Default
centers (array_like, shape(n_points, 2))

Ellipse centers, shared by every field.

required
covs array_like or list

The covariances to draw, in any of three spellings:

  • (n_points, 2, 2) -- a single field.
  • (n_samples, n_points, 2, 2) -- several fields, e.g. draws from :meth:~psyphy.posterior.WPPMPredictivePosterior.rsample.
  • a list of (n_points, 2, 2) arrays -- several fields to overlay, e.g. a published field and a fitted one.
required
ax Axes

Axes to draw into. A new figure and axes are created when omitted. Nothing is ever saved or shown; the caller owns the figure.

None
scale float or 'auto'

Multiplies every semi-axis. 1.0 draws true size. "auto" calls :func:~psyphy.viz.geometry.auto_scale on the first field and applies that one factor to all of them, so relative sizes stay comparable. Note "auto" may shrink as well as magnify.

1.0
colors color or array or list

A matplotlib color applied to a whole field, or an (n_points, 3|4) array giving one color per ellipse (e.g. from :func:psyphy.data.published.hong2025.w2d_to_rgb). Pass a list of length n_fields to set each field separately. Defaults to matplotlib's color cycle.

None
labels str or list of str

Legend entries, one per field. A legend is drawn only if any is given.

None
linestyles optional

Per-field line styling, broadcast the same way as colors.

None
linewidths optional

Per-field line styling, broadcast the same way as colors.

None
alpha optional

Per-field line styling, broadcast the same way as colors.

None
show_centers bool

Also scatter the center points.

False
skip_non_pd bool

Skip non-positive-definite covariances (and warn, naming the count) rather than raising.

True
return_scale bool

Also return the scale factor actually used -- worth doing with scale="auto", so the figure can report its own magnification.

False
n_points int

Vertices per ellipse.

100

Returns:

Type Description
matplotlib.axes.Axes, or (Axes, float) when ``return_scale=True``

Examples:

One field, true size::

1
ax = plot_ellipses(coords, Sigma)

Two fields overlaid, sized to the grid, reporting the factor::

1
2
3
4
5
6
7
8
9
ax, s = plot_ellipses(
    coords,
    [Sigma_published, Sigma_fit],
    scale="auto",
    colors=["black", "crimson"],
    linestyles=["--", "-"],
    labels=["published", "psyphy"],
    return_scale=True,
)

Posterior draws as an uncertainty band::

1
ax = plot_ellipses(coords, cov_samples, alpha=0.15)
Source code in src/psyphy/viz/ellipses.py
def plot_ellipses(
    centers: np.ndarray,
    covs: Any,
    *,
    ax: Any = None,
    scale: float | str = 1.0,
    colors: Any = None,
    labels: Any = None,
    linestyles: Any = None,
    linewidths: Any = None,
    alpha: Any = None,
    show_centers: bool = False,
    skip_non_pd: bool = True,
    return_scale: bool = False,
    n_points: int = 100,
) -> Any:
    """Draw one or more fields of 2-D covariance ellipses.

    Parameters
    ----------
    centers : array_like, shape (n_points, 2)
        Ellipse centers, shared by every field.
    covs : array_like or list
        The covariances to draw, in any of three spellings:

        - ``(n_points, 2, 2)`` -- a single field.
        - ``(n_samples, n_points, 2, 2)`` -- several fields, e.g. draws from
          :meth:`~psyphy.posterior.WPPMPredictivePosterior.rsample`.
        - a list of ``(n_points, 2, 2)`` arrays -- several fields to overlay,
          e.g. a published field and a fitted one.
    ax : matplotlib.axes.Axes, optional
        Axes to draw into. A new figure and axes are created when omitted.
        Nothing is ever saved or shown; the caller owns the figure.
    scale : float or "auto", default=1.0
        Multiplies every semi-axis. ``1.0`` draws true size. ``"auto"`` calls
        :func:`~psyphy.viz.geometry.auto_scale` on the **first** field and
        applies that one factor to all of them, so relative sizes stay
        comparable. Note ``"auto"`` may shrink as well as magnify.
    colors : color or array or list, optional
        A matplotlib color applied to a whole field, or an ``(n_points, 3|4)``
        array giving one color per ellipse (e.g. from
        :func:`psyphy.data.published.hong2025.w2d_to_rgb`). Pass a list of
        length ``n_fields`` to set each field separately. Defaults to
        matplotlib's color cycle.
    labels : str or list of str, optional
        Legend entries, one per field. A legend is drawn only if any is given.
    linestyles, linewidths, alpha : optional
        Per-field line styling, broadcast the same way as ``colors``.
    show_centers : bool, default=False
        Also scatter the center points.
    skip_non_pd : bool, default=True
        Skip non-positive-definite covariances (and warn, naming the count)
        rather than raising.
    return_scale : bool, default=False
        Also return the scale factor actually used -- worth doing with
        ``scale="auto"``, so the figure can report its own magnification.
    n_points : int, default=100
        Vertices per ellipse.

    Returns
    -------
    matplotlib.axes.Axes, or (Axes, float) when ``return_scale=True``

    Examples
    --------
    One field, true size::

        ax = plot_ellipses(coords, Sigma)

    Two fields overlaid, sized to the grid, reporting the factor::

        ax, s = plot_ellipses(
            coords,
            [Sigma_published, Sigma_fit],
            scale="auto",
            colors=["black", "crimson"],
            linestyles=["--", "-"],
            labels=["published", "psyphy"],
            return_scale=True,
        )

    Posterior draws as an uncertainty band::

        ax = plot_ellipses(coords, cov_samples, alpha=0.15)
    """
    plt = _require_matplotlib()
    from matplotlib.collections import LineCollection

    centers = np.asarray(centers, dtype=float)
    fields = _as_fields(covs)
    n_fields = len(fields)
    if n_fields == 0:
        raise ValueError("covs contained no covariance fields.")

    if isinstance(scale, str):
        if scale != "auto":
            raise ValueError(f"scale must be a float or 'auto'; got {scale!r}.")
        # One factor from the first field, applied to all: independently scaled
        # fields cannot be compared by eye.
        scale_value = auto_scale(centers, fields[0])
    else:
        # Deliberately one scalar for every field, never one per field:
        # independently magnified fields cannot be compared by eye, which is
        # the whole point of drawing them on shared axes.
        try:
            scale_value = float(scale)
        except (TypeError, ValueError) as exc:
            raise TypeError(
                f"scale must be a single number or 'auto'; got {type(scale).__name__}. "
                "One factor is applied to every field on purpose -- scaling them "
                "independently would make unlike fields look alike."
            ) from exc

    colors_pf = _per_field(colors, n_fields)
    labels_pf = _per_field(labels, n_fields)
    styles_pf = _per_field(linestyles, n_fields)
    widths_pf = _per_field(linewidths, n_fields)
    alpha_pf = _per_field(alpha, n_fields)

    if ax is None:
        _, ax = plt.subplots(figsize=(6, 6))

    cycle = plt.rcParams["axes.prop_cycle"].by_key().get("color", ["C0"])
    n_skipped_total = 0

    for i, field in enumerate(fields):
        segments, valid = ellipse_segments(
            centers, field, scale=scale_value, n_points=n_points
        )
        n_skipped = int((~valid).sum())
        if n_skipped and not skip_non_pd:
            raise ValueError(
                f"field {i} has {n_skipped} non-positive-definite covariance(s); "
                "pass skip_non_pd=True to draw the rest."
            )
        n_skipped_total += n_skipped

        color = colors_pf[i] if colors_pf[i] is not None else cycle[i % len(cycle)]
        seg_colors = (
            np.asarray(color)[valid]
            if isinstance(color, np.ndarray) and np.ndim(color) == 2
            else color
        )

        ax.add_collection(
            LineCollection(
                segments,
                colors=seg_colors,
                linestyles=styles_pf[i] or "solid",
                linewidths=widths_pf[i] if widths_pf[i] is not None else 1.4,
                alpha=alpha_pf[i],
            )
        )
        if show_centers:
            ax.scatter(
                centers[:, 0],
                centers[:, 1],
                c=color,
                s=10,
                zorder=5,
                edgecolors="none",
            )
        if labels_pf[i] is not None:
            # Proxy artist: a LineCollection does not produce a legend entry.
            proxy_color = color if not isinstance(color, np.ndarray) else "0.3"
            ax.plot(
                [],
                [],
                color=proxy_color,
                linestyle=styles_pf[i] or "solid",
                linewidth=widths_pf[i] if widths_pf[i] is not None else 1.4,
                label=labels_pf[i],
            )

    if n_skipped_total:
        warnings.warn(
            f"{n_skipped_total} non-positive-definite covariance(s) were not "
            "drawn; the plotted field is incomplete.",
            stacklevel=2,
        )

    ax.autoscale_view()
    ax.set_aspect("equal")
    if any(x is not None for x in labels_pf):
        ax.legend(fontsize=8)

    return (ax, scale_value) if return_scale else ax

geometry

geometry

Covariance-to-ellipse geometry, with no plotting dependency.

These helpers turn stacks of 2x2 covariance matrices into polyline vertices that any plotting backend can draw, and compute a magnification that makes a field of ellipses legible on its own grid. They import only numpy and scipy, both core psyphy dependencies, so they can be used (and tested) without matplotlib installed.

See :mod:psyphy.viz.ellipses for the matplotlib drawing layer.

Functions:

Name Description
auto_scale

Scale factor that fits a field of ellipses to its own grid.

ellipse_segments

Convert a field of covariances into closed polylines.

auto_scale

auto_scale(centers: ndarray, covs: ndarray, *, fraction: float = 0.35) -> float

Scale factor that fits a field of ellipses to its own grid.

Sizes the median ellipse so its typical radius is fraction of the median nearest-neighbour spacing between centers. This adapts to the grid rather than assuming one: the same call gives a readable figure on a coarse 7x7 grid and on a dense 103x103 one.

Parameters:

Name Type Description Default
centers (array_like, shape(n_points, 2))

Ellipse centers. At least two are required, since the spacing is a nearest-neighbour distance.

required
covs (array_like, shape(n_points, 2, 2))

Covariance matrix per center.

required
fraction float

Target ratio of typical ellipse radius to typical center spacing.

0.35

Returns:

Type Description
float

Multiplier to pass as scale.

Notes

This is not always a magnification. On a dense grid the factor can be well below 1, shrinking ellipses so neighbours do not overlap. On the Hong et al. (2025) data it is ~1.24 for the 49-point threshold grid and ~0.22 for the 10 609-point noise grid.

Because it rescales, a figure drawn with it cannot be read for absolute size. When comparing several fields, compute the factor once and apply it to all of them, or relative sizes become meaningless; :func:psyphy.viz.plot_ellipses enforces this.

Uses a KD-tree rather than a full pairwise distance matrix, which would be O(n^2) in time and memory -- negligible at n=49, about 1.8 GB at n=10 609.

Source code in src/psyphy/viz/geometry.py
def auto_scale(
    centers: np.ndarray, covs: np.ndarray, *, fraction: float = 0.35
) -> float:
    """Scale factor that fits a field of ellipses to its own grid.

    Sizes the *median* ellipse so its typical radius is ``fraction`` of the
    median nearest-neighbour spacing between centers. This adapts to the grid
    rather than assuming one: the same call gives a readable figure on a coarse
    7x7 grid and on a dense 103x103 one.

    Parameters
    ----------
    centers : array_like, shape (n_points, 2)
        Ellipse centers. At least two are required, since the spacing is a
        nearest-neighbour distance.
    covs : array_like, shape (n_points, 2, 2)
        Covariance matrix per center.
    fraction : float, default=0.35
        Target ratio of typical ellipse radius to typical center spacing.

    Returns
    -------
    float
        Multiplier to pass as ``scale``.

    Notes
    -----
    **This is not always a magnification.** On a dense grid the factor can be
    well below 1, shrinking ellipses so neighbours do not overlap. On the Hong
    et al. (2025) data it is ~1.24 for the 49-point threshold grid and ~0.22
    for the 10 609-point noise grid.

    Because it rescales, a figure drawn with it cannot be read for *absolute*
    size. When comparing several fields, compute the factor **once** and apply
    it to all of them, or relative sizes become meaningless;
    :func:`psyphy.viz.plot_ellipses` enforces this.

    Uses a KD-tree rather than a full pairwise distance matrix, which would be
    O(n^2) in time and memory -- negligible at n=49, about 1.8 GB at n=10 609.
    """
    centers, covs = _validate_field(centers, covs)
    if len(centers) < 2:
        raise ValueError(
            "auto_scale needs at least 2 centers to measure spacing; got "
            f"{len(centers)}. Pass an explicit scale instead."
        )

    dists, _ = cKDTree(centers).query(centers, k=2)  # k=2: [0] is the point itself
    spacing = float(np.median(dists[:, 1]))
    typical_radius = float(np.median(np.sqrt(np.linalg.eigvalsh(covs).mean(-1))))

    if not np.isfinite(typical_radius) or typical_radius <= 0.0:
        raise ValueError(
            "could not determine a typical ellipse radius (non-finite or "
            "non-positive covariances); pass an explicit scale."
        )
    return fraction * spacing / typical_radius

ellipse_segments

ellipse_segments(centers: ndarray, covs: ndarray, *, scale: float = 1.0, n_points: int = 100) -> tuple[list[ndarray], ndarray]

Convert a field of covariances into closed polylines.

Each covariance is drawn as its 1-standard-deviation ellipse, {c + scale * L u : |u| = 1} where L is the Cholesky factor of the covariance. The ellipse's semi-axes are therefore scale * sqrt(lambda_i) for eigenvalues lambda_i.

Parameters:

Name Type Description Default
centers (array_like, shape(n_points, 2))

Ellipse centers, typically the reference stimuli.

required
covs (array_like, shape(n_points, 2, 2))

Covariance matrix per center.

required
scale float

Multiplies every semi-axis. 1.0 draws true size; see :func:auto_scale for a grid-aware value.

1.0
n_points int

Vertices per ellipse.

100

Returns:

Name Type Description
segments list of np.ndarray

One (n_points, 2) array per positive-definite covariance, in input order. Shorter than centers when some are skipped.

valid np.ndarray of bool, shape (n_points,)

Which covariances were positive-definite and therefore drawn.

Notes

Non-positive-definite covariances are skipped rather than raising, because a single bad matrix should not discard an otherwise usable field. Callers should surface valid.sum() < len(valid) to the user rather than ignore it -- a silently thinned field is hard to notice by eye.

Examples:

1
2
3
4
>>> import numpy as np
>>> segs, valid = ellipse_segments(np.zeros((1, 2)), np.eye(2)[None])
>>> bool(valid.all()), segs[0].shape
(True, (100, 2))
Source code in src/psyphy/viz/geometry.py
def ellipse_segments(
    centers: np.ndarray,
    covs: np.ndarray,
    *,
    scale: float = 1.0,
    n_points: int = 100,
) -> tuple[list[np.ndarray], np.ndarray]:
    """Convert a field of covariances into closed polylines.

    Each covariance is drawn as its 1-standard-deviation ellipse,
    ``{c + scale * L u : |u| = 1}`` where ``L`` is the Cholesky factor of the
    covariance. The ellipse's semi-axes are therefore ``scale * sqrt(lambda_i)``
    for eigenvalues ``lambda_i``.

    Parameters
    ----------
    centers : array_like, shape (n_points, 2)
        Ellipse centers, typically the reference stimuli.
    covs : array_like, shape (n_points, 2, 2)
        Covariance matrix per center.
    scale : float, default=1.0
        Multiplies every semi-axis. ``1.0`` draws true size; see
        :func:`auto_scale` for a grid-aware value.
    n_points : int, default=100
        Vertices per ellipse.

    Returns
    -------
    segments : list of np.ndarray
        One ``(n_points, 2)`` array per **positive-definite** covariance, in
        input order. Shorter than ``centers`` when some are skipped.
    valid : np.ndarray of bool, shape (n_points,)
        Which covariances were positive-definite and therefore drawn.

    Notes
    -----
    Non-positive-definite covariances are skipped rather than raising, because
    a single bad matrix should not discard an otherwise usable field. Callers
    should surface ``valid.sum() < len(valid)`` to the user rather than ignore
    it -- a silently thinned field is hard to notice by eye.

    Examples
    --------
    >>> import numpy as np
    >>> segs, valid = ellipse_segments(np.zeros((1, 2)), np.eye(2)[None])
    >>> bool(valid.all()), segs[0].shape
    (True, (100, 2))
    """
    centers, covs = _validate_field(centers, covs)

    valid = np.all(np.linalg.eigvalsh(covs) > 0, axis=-1)
    theta = np.linspace(0.0, 2 * np.pi, n_points)
    circle = np.vstack([np.cos(theta), np.sin(theta)])

    segments = [
        (c[:, None] + scale * (np.linalg.cholesky(S) @ circle)).T
        for c, S, ok in zip(centers, covs, valid, strict=True)
        if ok
    ]
    return segments, valid