Skip to content

KDEGlyph Class#

The KDEGlyph class evaluates an isotropic Gaussian kernel-density estimate of an (x, y) point cloud on a regular grid — numpy only, no scipy — and draws it as filled (shade=True, the default) or line density contours, coloured through the shared scalar-mapping pipeline. An optional clip_path restricts the drawn contours.

Class Documentation#

cleopatra.glyphs.stats.kde_glyph.KDEGlyph #

Bases: Glyph

Visualization class for 2-D kernel-density estimates.

Evaluates an isotropic Gaussian KDE of a (x, y) point cloud on a regular grid (numpy only, no scipy) and draws it as filled or line density contours, coloured through the shared scalar-mapping pipeline.

Parameters:

Name Type Description Default
x ndarray

1D array of point x-coordinates.

required
y ndarray

1D array of point y-coordinates. Must match the length of x.

required
clip_path Path | Patch | None

Optional matplotlib Path or Patch that clips the drawn contours (e.g. a country/basin outline supplied by the caller). A Patch is used directly; a Path is interpreted in data coordinates. Default is None (no clipping).

None
ax Axes | None

Pre-existing axes to draw on. Default is None.

None
fig Figure | None

Pre-existing figure. Default is None.

None
**kwargs

Construction-time overrides for the non-grouped KDE_DEFAULT_OPTIONS: shade (filled contourf vs line contour, default True), bw_method (None for Scott's rule, or a positive float bandwidth multiplier), gridsize (density grid resolution, default 100), plus the shared appearance / colorbar options (cmap, vmin, vmax, ticks_spacing, cbar_label, figsize, title). Set add_colorbar=False to suppress the per-glyph colorbar (default True). The colour scale, density levels, and preset / relief shading are no longer construction kwargs -- pass them to plot() via color=ColorScaling(...), contour=Contour(levels=...), and data_style=DataStyle(...) respectively (a loose color_scale / levels / style keyword now raises).

{}

Raises:

Type Description
ValueError

If x and y have mismatched shapes, if fewer than two points are given, if bw_method is non-positive, or if a coordinate has zero spread (a degenerate kernel).

Examples:

  • Evaluate the density grid directly (no rendering):
    >>> import numpy as np
    >>> from cleopatra.glyphs.stats.kde_glyph import KDEGlyph
    >>> rng = np.random.default_rng(1)
    >>> x = rng.normal(size=500)
    >>> y = rng.normal(size=500)
    >>> glyph = KDEGlyph(x, y, gridsize=50)
    >>> gx, gy, density = glyph.evaluate()
    >>> density.shape
    (50, 50)
    >>> bool(density.sum() > 0)
    True
    
See Also

cleopatra.glyphs.base.glyph.Glyph._prepare_scalar_mapping: Shared norm/colorbar/ticks pipeline used to colour the density. cleopatra.glyphs.gridded.mesh_glyph.MeshGlyph: Contour rendering for unstructured meshes.

Source code in src/cleopatra/glyphs/stats/kde_glyph.py
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
class KDEGlyph(Glyph):
    """Visualization class for 2-D kernel-density estimates.

    Evaluates an isotropic Gaussian KDE of a `(x, y)` point cloud on a
    regular grid (numpy only, no scipy) and draws it as filled or line
    density contours, coloured through the shared scalar-mapping pipeline.

    Args:
        x: 1D array of point x-coordinates.
        y: 1D array of point y-coordinates. Must match the length of `x`.
        clip_path: Optional matplotlib `Path` or `Patch` that clips the
            drawn contours (e.g. a country/basin outline supplied by the
            caller). A `Patch` is used directly; a `Path` is interpreted in
            data coordinates. Default is None (no clipping).
        ax: Pre-existing axes to draw on. Default is None.
        fig: Pre-existing figure. Default is None.
        **kwargs: Construction-time overrides for the non-grouped
            `KDE_DEFAULT_OPTIONS`: `shade` (filled `contourf` vs line
            `contour`, default True), `bw_method` (None for Scott's rule,
            or a positive float bandwidth multiplier), `gridsize` (density
            grid resolution, default 100), plus the shared appearance /
            colorbar options (`cmap`, `vmin`, `vmax`, `ticks_spacing`,
            `cbar_label`, `figsize`, `title`). Set `add_colorbar=False` to
            suppress the per-glyph colorbar (default True). The colour
            scale, density `levels`, and preset / relief shading are no
            longer construction kwargs -- pass them to `plot()` via
            `color=ColorScaling(...)`, `contour=Contour(levels=...)`, and
            `data_style=DataStyle(...)` respectively (a loose `color_scale`
            / `levels` / `style` keyword now raises).

    Raises:
        ValueError: If `x` and `y` have mismatched shapes, if fewer than
            two points are given, if `bw_method` is non-positive, or if a
            coordinate has zero spread (a degenerate kernel).

    Examples:
        - Evaluate the density grid directly (no rendering):
            ```python
            >>> import numpy as np
            >>> from cleopatra.glyphs.stats.kde_glyph import KDEGlyph
            >>> rng = np.random.default_rng(1)
            >>> x = rng.normal(size=500)
            >>> y = rng.normal(size=500)
            >>> glyph = KDEGlyph(x, y, gridsize=50)
            >>> gx, gy, density = glyph.evaluate()
            >>> density.shape
            (50, 50)
            >>> bool(density.sum() > 0)
            True

            ```

    See Also:
        cleopatra.glyphs.base.glyph.Glyph._prepare_scalar_mapping: Shared
            norm/colorbar/ticks pipeline used to colour the density.
        cleopatra.glyphs.gridded.mesh_glyph.MeshGlyph: Contour rendering for unstructured
            meshes.
    """

    #: Option keys this glyph accepts (see `Glyph.option_keys`/`filter_kwargs`).
    DEFAULT_OPTIONS = KDE_DEFAULT_OPTIONS

    def __init__(
        self,
        x: np.ndarray,
        y: np.ndarray,
        *,
        clip_path: MplPath | Patch | None = None,
        ax: Axes | None = None,
        fig: Figure | None = None,
        **kwargs,
    ):
        super().__init__(default_options=KDE_DEFAULT_OPTIONS, fig=fig, ax=ax, **kwargs)
        self.x = np.asarray(x, dtype=float)
        self.y = np.asarray(y, dtype=float)
        if self.x.shape != self.y.shape:
            raise ValueError(
                f"x and y must have the same shape, got {self.x.shape} "
                f"and {self.y.shape}."
            )
        if self.x.size < 2:
            raise ValueError(f"KDE needs at least 2 points, got {self.x.size}.")
        bw_method = self.default_options["bw_method"]
        if bw_method is not None and bw_method <= 0:
            raise ValueError(
                f"bw_method must be a positive float or None, got {bw_method}."
            )
        self.clip_path = clip_path
        self.cbar: Colorbar | None = None
        #: The `AxesImage` (hillshaded) or `QuadContourSet` mappable from
        #: the most recent `plot` call; `None` before first render.
        self.im: Any = None

    def _bandwidth(self) -> float:
        """Return Scott's-rule bandwidth, scaled by the `bw_method` option.

        Scott's rule in `d` dimensions is `n ** (-1 / (d + 4))`; for the 2-D
        estimator here that is `n ** (-1 / 6)`. The optional `bw_method`
        multiplier (default 1.0) widens (`> 1`) or narrows (`< 1`) the kernel.

        Returns:
            float: The bandwidth factor applied to each coordinate's
                standard deviation.
        """
        n = self.x.size
        multiplier = self.default_options["bw_method"] or 1.0
        return float(multiplier * n ** (-1.0 / 6.0))

    def evaluate(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
        """Evaluate the KDE on a regular grid spanning the point bounds.

        Builds a `gridsize × gridsize` grid over the `[x.min, x.max] ×
        [y.min, y.max]` bounding box and sums an isotropic Gaussian kernel
        (Scott's-rule bandwidth) over the points. The sum is chunked over
        the data points so memory stays bounded (see `MAX_KDE_BLOCK`) even
        for large `gridsize` or point counts.

        Returns:
            tuple[np.ndarray, np.ndarray, np.ndarray]: The grid `gx`, `gy`
                (each `gridsize × gridsize`) and the density evaluated on
                that grid (same shape), normalised to integrate to ~1.

        Raises:
            ValueError: If either coordinate has zero spread (its standard
                deviation is 0), which would give a degenerate kernel.

        Examples:
            - The density peaks near a tight synthetic cluster:
                ```python
                >>> import numpy as np
                >>> from cleopatra.glyphs.stats.kde_glyph import KDEGlyph
                >>> rng = np.random.default_rng(2)
                >>> pts = rng.normal(scale=0.1, size=300)
                >>> x = np.concatenate([pts, pts + 5.0])
                >>> y = np.concatenate([pts, pts + 5.0])
                >>> gx, gy, d = KDEGlyph(x, y, gridsize=60).evaluate()
                >>> peak = np.unravel_index(int(np.argmax(d)), d.shape)
                >>> bool(min(abs(gx[peak] - 0.0), abs(gx[peak] - 5.0)) < 1.0)
                True

                ```
        """
        x, y = self.x, self.y
        n = x.size
        bw = self._bandwidth()
        sx, sy = x.std() * bw, y.std() * bw
        if sx == 0 or sy == 0:
            raise ValueError(
                "Cannot build a KDE: a coordinate has zero spread "
                "(degenerate kernel). Provide points that vary in x and y."
            )

        gridsize = int(self.default_options["gridsize"])
        gx, gy = np.meshgrid(
            np.linspace(x.min(), x.max(), gridsize),
            np.linspace(y.min(), y.max(), gridsize),
        )
        gx_flat = gx.ravel()[:, None]
        gy_flat = gy.ravel()[:, None]

        block = max(1, MAX_KDE_BLOCK // gx_flat.shape[0])
        density_flat = np.zeros(gx_flat.shape[0], dtype=float)
        for start in range(0, n, block):
            xs = x[start : start + block]
            ys = y[start : start + block]
            dx = (gx_flat - xs) / sx
            dy = (gy_flat - ys) / sy
            density_flat += np.exp(-0.5 * (dx**2 + dy**2)).sum(axis=1)

        density = density_flat.reshape(gx.shape) / (2.0 * np.pi * sx * sy * n)
        return gx, gy, density

    def _resolve_levels(self, density: np.ndarray) -> np.ndarray:
        """Resolve the `levels` option to explicit, increasing density edges.

        An integer becomes that many evenly-spaced edges across the density
        range; an explicit sequence is sorted and used verbatim. Returning
        explicit edges (rather than an int) keeps `contourf`/`contour` in
        step with the `BoundaryNorm` the shared pipeline builds from the
        same `levels` option.

        Args:
            density: The evaluated density grid (for its value range).

        Returns:
            np.ndarray: The sorted, increasing contour level edges.
        """
        levels = self.default_options["levels"]
        if isinstance(levels, (int, np.integer)) and not isinstance(levels, bool):
            return np.linspace(float(density.min()), float(density.max()), int(levels))
        return np.sort(np.asarray(levels, dtype=float))

    def _apply_clip(self, contour_set: Any) -> None:
        """Clip the drawn contour set to `self.clip_path`, if any.

        A `Patch` clips in data coordinates; a `Path` is clipped in data
        coordinates (`ax.transData`). No-op when no clip path was supplied.

        A `Patch` clips through its own transform. A patch the caller just
        constructed (and has not added to an axes) carries an identity
        transform, which would clip in display space rather than data space.
        Rather than mutate the caller's patch, an unattached patch is clipped
        against its geometry directly — its `Path` under
        `patch_transform + ax.transData` — which is what `Axes.add_patch`
        would resolve to. A patch already added to an axes is used as-is
        (its own transform is honoured).

        Args:
            contour_set: The `QuadContourSet` returned by
                `contourf`/`contour`.

        Raises:
            TypeError: If `clip_path` is neither a matplotlib `Path` nor a
                `Patch`.
        """
        clip = self.clip_path
        if clip is None:
            return
        # `_apply_clip` is only called from `plot()` after `self.ax` is resolved.
        assert self.ax is not None
        if isinstance(clip, Patch):
            if clip.axes is None:
                # Clip in data coordinates without mutating the caller's patch.
                transform = clip.get_patch_transform() + self.ax.transData
                contour_set.set_clip_path(clip.get_path(), transform)
            else:
                contour_set.set_clip_path(clip)
        elif isinstance(clip, MplPath):
            contour_set.set_clip_path(clip, transform=self.ax.transData)
        else:
            raise TypeError(
                "clip_path must be a matplotlib Path or Patch, got "
                f"{type(clip).__name__}."
            )

    @property
    def style(self) -> str | None:
        """Name of the `DATA_STYLES` preset currently applied, or `None`.

        Reads back the preset set via the `style` constructor kwarg, a
        `plot(style=...)` call, or `apply_style`.
        """
        return self.default_options.get("style")

    def apply_style(
        self,
        style: str,
        *,
        hillshade: bool | dict | None | _Unset = _UNSET,
        add_colorbar: bool | None = None,
        title: str | None = None,
    ):
        """Apply a continuous `DATA_STYLES` preset by name, re-rendering in place.

        A discoverable wrapper over `plot(style=...)` for restyling an
        already-built glyph. It redraws **in place** on the glyph's own axes
        (taking full ownership -- do not use on a shared axes), or on a fresh
        figure if the glyph was never plotted or its figure was closed. The
        applied style is **sticky** (survives a later plain `plot()`);
        `plot(style=None)` clears it.

        Args:
            style: A continuous `cleopatra.styling.colors.DATA_STYLES` preset name.
            hillshade: Optional relief shading, forwarded to `plot`.
            add_colorbar: Optional colorbar toggle, forwarded to `plot`.
            title: Optional title, forwarded to `plot`.

        Returns:
            tuple[Figure, Axes, QuadContourSet]: The `plot` result.

        Raises:
            ValueError: If `style` is unknown, or is categorical (a density is
                continuous).
        """
        _, cfg = resolve_single_layer_style(style)
        if cfg.get("categories") is not None:
            raise ValueError(
                f"data style {style!r} is categorical; KDEGlyph colours a "
                "continuous density, so only continuous presets apply"
            )
        self._reset_axes_for_restyle()
        # Only override hillshade when the caller actually passed one; an
        # unset value keeps any sticky relief shading, while an explicit
        # `None` flows through to `DataStyle(hillshade=None)` and clears it.
        data_style = DataStyle.for_apply_style(style, hillshade=hillshade)
        return self.plot(
            ax=self.ax,
            title=title,
            add_colorbar=add_colorbar,
            data_style=data_style,
        )

    def plot(
        self,
        ax: Axes | None = None,
        title: str | None = None,
        add_colorbar: bool | None = None,
        colorbar: bool | ColorBar | None = None,
        color: ColorScaling | None = None,
        contour: Contour | None = None,
        data_style: DataStyle | None = None,
    ):
        """Render the 2-D density as filled or line contours.

        Evaluates the KDE via `evaluate`, colours it through
        `_prepare_scalar_mapping`, and draws `contourf` (when `shade`) or
        `contour` (otherwise). An optional `clip_path` restricts the drawn
        contours.

        Args:
            ax: Axes to draw on. Falls back to the axes supplied at
                construction, otherwise a new figure/axes is created.
            title: Plot title. Overrides `default_options["title"]` when
                given.
            add_colorbar: Override the `add_colorbar` option for this call
                — True draws the colorbar, False suppresses it. Defaults to
                None, which keeps the value set at construction.
            colorbar: Typed `ColorBar` spec (or `True`/`False`/`None`) for the
                colorbar's placement, caption, and sizing; resolved into the
                `cbar_*` options. A `ColorBar`/`True` also enables the bar and is
                **sticky** -- it persists into later plots, overriding a
                construction-time `add_colorbar=False`; an explicit
                `add_colorbar=` argument still wins the on/off decision.
            hillshade: Relief-shade the density surface for this call (`True`
                or an options dict; see `cleopatra.glyphs.base.hillshade`). Defaults to
                None, which keeps the value set at construction. Accepting it
                here mirrors `ArrayGlyph.plot`/`MeshGlyph.plot`, so `hillshade`
                works the same way across all three glyphs.
            style: Name of a continuous `cleopatra.styling.colors.DATA_STYLES` preset
                to colour the density with (its cmap + norm; composes with
                `hillshade`). The preset name is **sticky** -- once set it
                persists into `default_options` and survives later plain
                `plot()` calls (like `ArrayGlyph`), and `self.style` reads it
                back; the resolved cmap is not persisted, so it never leaks.
                Not passing `style` keeps the current preset; passing
                `style=None` clears it back to the plain density colouring
                (unlike `hillshade`, which reverts to its construction value).
                A categorical preset has no meaning for a continuous density
                and raises `ValueError`. Valid names:
                `sorted(cleopatra.styling.colors.DATA_STYLES)`.

        Returns:
            tuple[Figure, Axes, QuadContourSet]: The figure, the axes, and
                the contour set (the mappable the colorbar attaches to).

        Raises:
            ValueError: If a coordinate has zero spread (via `evaluate`).
            TypeError: If `clip_path` is an unsupported type (via the clip
                step).

        Examples:
            - Filled contours add a colorbar by default:
                ```python
                >>> import numpy as np
                >>> from cleopatra.glyphs.stats.kde_glyph import KDEGlyph
                >>> rng = np.random.default_rng(3)
                >>> x, y = rng.normal(size=300), rng.normal(size=300)
                >>> glyph = KDEGlyph(x, y, gridsize=40)
                >>> fig, ax, cs = glyph.plot()
                >>> glyph.cbar is not None
                True

                ```
            - Line contours (`shade=False`) and no colorbar:
                ```python
                >>> import numpy as np
                >>> from cleopatra.glyphs.stats.kde_glyph import KDEGlyph
                >>> rng = np.random.default_rng(4)
                >>> x, y = rng.normal(size=300), rng.normal(size=300)
                >>> glyph = KDEGlyph(x, y, gridsize=40, shade=False)
                >>> fig, ax, cs = glyph.plot(add_colorbar=False)
                >>> glyph.cbar is None
                True

                ```
        """
        # Snapshot every option key the group objects will touch before
        # merging, so an invalid preset rolls back the WHOLE merge -- not
        # just style -- so a co-passed color=/contour= cannot leak into a
        # later plain plot.
        prev_group_opts = self._snapshot_group_options(color, contour, data_style)
        self._merge_group_params(color, contour, data_style)

        if ax is not None:
            self.ax = ax
            self.fig = _root_figure(ax)
        elif self.ax is None:
            self.fig, self.ax = self.create_figure_axes()
        ax = self.ax
        opts = self.default_options

        if title is not None:
            opts["title"] = title
        opts.update(_resolve_colorbar(colorbar))
        draw_colorbar = opts["add_colorbar"] if add_colorbar is None else add_colorbar

        gx, gy, density = self.evaluate()
        level_edges = self._resolve_levels(density)
        norm, cbar_kw, _ = self._prepare_scalar_mapping(density)
        cmap = resolve_colormap(opts["cmap"])

        style = opts.get("style")
        if style is not None:
            try:
                _, cfg = resolve_single_layer_style(style)
                if cfg.get("categories") is not None:
                    raise ValueError(
                        f"data style {style!r} is categorical; KDEGlyph colours "
                        "a continuous density, so only continuous presets apply"
                    )
            except ValueError:
                for key, value in prev_group_opts.items():
                    opts[key] = value
                raise
            cfg = {
                **cfg,
                **{k: opts[k] for k in ("vmin", "vmax") if opts.get(k) is not None},
            }
            cmap = resolve_colormap(cfg["cmap"])
            norm, _, _ = resolve_style_norm(np.asarray(density, dtype=float), cfg)
            # Drop the linear ticks so the colorbar matches the preset norm.
            cbar_kw.pop("ticks", None)

        hillshade = resolve_hillshade(opts.get("hillshade"))
        if hillshade is not None:
            hs_norm = (
                norm
                if norm is not None
                else Normalize(vmin=float(density.min()), vmax=float(density.max()))
            )
            rgba = shade_grid(density, cmap, norm=hs_norm, **hillshade)
            extent = (
                float(gx.min()),
                float(gx.max()),
                float(gy.min()),
                float(gy.max()),
            )
            mappable = ax.imshow(rgba, extent=extent, origin="lower", aspect="auto")
            self._apply_clip(mappable)
            self.im = mappable
            if draw_colorbar:
                proxy = ScalarMappable(norm=hs_norm, cmap=cmap)
                proxy.set_array(density)
                self.cbar = self.create_color_bar(ax, proxy, cbar_kw)
            if opts["title"]:
                ax.set_title(opts["title"], fontsize=opts["title_size"])
            return self.fig, ax, mappable

        render = ax.contourf if opts["shade"] else ax.contour
        contour_set = render(gx, gy, density, levels=level_edges, cmap=cmap, norm=norm)
        self._apply_clip(contour_set)
        self.im = contour_set

        if draw_colorbar:
            self.cbar = self.create_color_bar(ax, contour_set, cbar_kw)

        if opts["title"]:
            ax.set_title(opts["title"], fontsize=opts["title_size"])

        return self.fig, ax, contour_set

style property #

Name of the DATA_STYLES preset currently applied, or None.

Reads back the preset set via the style constructor kwarg, a plot(style=...) call, or apply_style.

apply_style(style, *, hillshade=_UNSET, add_colorbar=None, title=None) #

Apply a continuous DATA_STYLES preset by name, re-rendering in place.

A discoverable wrapper over plot(style=...) for restyling an already-built glyph. It redraws in place on the glyph's own axes (taking full ownership -- do not use on a shared axes), or on a fresh figure if the glyph was never plotted or its figure was closed. The applied style is sticky (survives a later plain plot()); plot(style=None) clears it.

Parameters:

Name Type Description Default
style str

A continuous cleopatra.styling.colors.DATA_STYLES preset name.

required
hillshade bool | dict | None | _Unset

Optional relief shading, forwarded to plot.

_UNSET
add_colorbar bool | None

Optional colorbar toggle, forwarded to plot.

None
title str | None

Optional title, forwarded to plot.

None

Returns:

Type Description

tuple[Figure, Axes, QuadContourSet]: The plot result.

Raises:

Type Description
ValueError

If style is unknown, or is categorical (a density is continuous).

Source code in src/cleopatra/glyphs/stats/kde_glyph.py
def apply_style(
    self,
    style: str,
    *,
    hillshade: bool | dict | None | _Unset = _UNSET,
    add_colorbar: bool | None = None,
    title: str | None = None,
):
    """Apply a continuous `DATA_STYLES` preset by name, re-rendering in place.

    A discoverable wrapper over `plot(style=...)` for restyling an
    already-built glyph. It redraws **in place** on the glyph's own axes
    (taking full ownership -- do not use on a shared axes), or on a fresh
    figure if the glyph was never plotted or its figure was closed. The
    applied style is **sticky** (survives a later plain `plot()`);
    `plot(style=None)` clears it.

    Args:
        style: A continuous `cleopatra.styling.colors.DATA_STYLES` preset name.
        hillshade: Optional relief shading, forwarded to `plot`.
        add_colorbar: Optional colorbar toggle, forwarded to `plot`.
        title: Optional title, forwarded to `plot`.

    Returns:
        tuple[Figure, Axes, QuadContourSet]: The `plot` result.

    Raises:
        ValueError: If `style` is unknown, or is categorical (a density is
            continuous).
    """
    _, cfg = resolve_single_layer_style(style)
    if cfg.get("categories") is not None:
        raise ValueError(
            f"data style {style!r} is categorical; KDEGlyph colours a "
            "continuous density, so only continuous presets apply"
        )
    self._reset_axes_for_restyle()
    # Only override hillshade when the caller actually passed one; an
    # unset value keeps any sticky relief shading, while an explicit
    # `None` flows through to `DataStyle(hillshade=None)` and clears it.
    data_style = DataStyle.for_apply_style(style, hillshade=hillshade)
    return self.plot(
        ax=self.ax,
        title=title,
        add_colorbar=add_colorbar,
        data_style=data_style,
    )

evaluate() #

Evaluate the KDE on a regular grid spanning the point bounds.

Builds a gridsize × gridsize grid over the [x.min, x.max] × [y.min, y.max] bounding box and sums an isotropic Gaussian kernel (Scott's-rule bandwidth) over the points. The sum is chunked over the data points so memory stays bounded (see MAX_KDE_BLOCK) even for large gridsize or point counts.

Returns:

Type Description
tuple[ndarray, ndarray, ndarray]

tuple[np.ndarray, np.ndarray, np.ndarray]: The grid gx, gy (each gridsize × gridsize) and the density evaluated on that grid (same shape), normalised to integrate to ~1.

Raises:

Type Description
ValueError

If either coordinate has zero spread (its standard deviation is 0), which would give a degenerate kernel.

Examples:

  • The density peaks near a tight synthetic cluster:
    >>> import numpy as np
    >>> from cleopatra.glyphs.stats.kde_glyph import KDEGlyph
    >>> rng = np.random.default_rng(2)
    >>> pts = rng.normal(scale=0.1, size=300)
    >>> x = np.concatenate([pts, pts + 5.0])
    >>> y = np.concatenate([pts, pts + 5.0])
    >>> gx, gy, d = KDEGlyph(x, y, gridsize=60).evaluate()
    >>> peak = np.unravel_index(int(np.argmax(d)), d.shape)
    >>> bool(min(abs(gx[peak] - 0.0), abs(gx[peak] - 5.0)) < 1.0)
    True
    
Source code in src/cleopatra/glyphs/stats/kde_glyph.py
def evaluate(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Evaluate the KDE on a regular grid spanning the point bounds.

    Builds a `gridsize × gridsize` grid over the `[x.min, x.max] ×
    [y.min, y.max]` bounding box and sums an isotropic Gaussian kernel
    (Scott's-rule bandwidth) over the points. The sum is chunked over
    the data points so memory stays bounded (see `MAX_KDE_BLOCK`) even
    for large `gridsize` or point counts.

    Returns:
        tuple[np.ndarray, np.ndarray, np.ndarray]: The grid `gx`, `gy`
            (each `gridsize × gridsize`) and the density evaluated on
            that grid (same shape), normalised to integrate to ~1.

    Raises:
        ValueError: If either coordinate has zero spread (its standard
            deviation is 0), which would give a degenerate kernel.

    Examples:
        - The density peaks near a tight synthetic cluster:
            ```python
            >>> import numpy as np
            >>> from cleopatra.glyphs.stats.kde_glyph import KDEGlyph
            >>> rng = np.random.default_rng(2)
            >>> pts = rng.normal(scale=0.1, size=300)
            >>> x = np.concatenate([pts, pts + 5.0])
            >>> y = np.concatenate([pts, pts + 5.0])
            >>> gx, gy, d = KDEGlyph(x, y, gridsize=60).evaluate()
            >>> peak = np.unravel_index(int(np.argmax(d)), d.shape)
            >>> bool(min(abs(gx[peak] - 0.0), abs(gx[peak] - 5.0)) < 1.0)
            True

            ```
    """
    x, y = self.x, self.y
    n = x.size
    bw = self._bandwidth()
    sx, sy = x.std() * bw, y.std() * bw
    if sx == 0 or sy == 0:
        raise ValueError(
            "Cannot build a KDE: a coordinate has zero spread "
            "(degenerate kernel). Provide points that vary in x and y."
        )

    gridsize = int(self.default_options["gridsize"])
    gx, gy = np.meshgrid(
        np.linspace(x.min(), x.max(), gridsize),
        np.linspace(y.min(), y.max(), gridsize),
    )
    gx_flat = gx.ravel()[:, None]
    gy_flat = gy.ravel()[:, None]

    block = max(1, MAX_KDE_BLOCK // gx_flat.shape[0])
    density_flat = np.zeros(gx_flat.shape[0], dtype=float)
    for start in range(0, n, block):
        xs = x[start : start + block]
        ys = y[start : start + block]
        dx = (gx_flat - xs) / sx
        dy = (gy_flat - ys) / sy
        density_flat += np.exp(-0.5 * (dx**2 + dy**2)).sum(axis=1)

    density = density_flat.reshape(gx.shape) / (2.0 * np.pi * sx * sy * n)
    return gx, gy, density

plot(ax=None, title=None, add_colorbar=None, colorbar=None, color=None, contour=None, data_style=None) #

Render the 2-D density as filled or line contours.

Evaluates the KDE via evaluate, colours it through _prepare_scalar_mapping, and draws contourf (when shade) or contour (otherwise). An optional clip_path restricts the drawn contours.

Parameters:

Name Type Description Default
ax Axes | None

Axes to draw on. Falls back to the axes supplied at construction, otherwise a new figure/axes is created.

None
title str | None

Plot title. Overrides default_options["title"] when given.

None
add_colorbar bool | None

Override the add_colorbar option for this call — True draws the colorbar, False suppresses it. Defaults to None, which keeps the value set at construction.

None
colorbar bool | ColorBar | None

Typed ColorBar spec (or True/False/None) for the colorbar's placement, caption, and sizing; resolved into the cbar_* options. A ColorBar/True also enables the bar and is sticky -- it persists into later plots, overriding a construction-time add_colorbar=False; an explicit add_colorbar= argument still wins the on/off decision.

None
hillshade

Relief-shade the density surface for this call (True or an options dict; see cleopatra.glyphs.base.hillshade). Defaults to None, which keeps the value set at construction. Accepting it here mirrors ArrayGlyph.plot/MeshGlyph.plot, so hillshade works the same way across all three glyphs.

required
style

Name of a continuous cleopatra.styling.colors.DATA_STYLES preset to colour the density with (its cmap + norm; composes with hillshade). The preset name is sticky -- once set it persists into default_options and survives later plain plot() calls (like ArrayGlyph), and self.style reads it back; the resolved cmap is not persisted, so it never leaks. Not passing style keeps the current preset; passing style=None clears it back to the plain density colouring (unlike hillshade, which reverts to its construction value). A categorical preset has no meaning for a continuous density and raises ValueError. Valid names: sorted(cleopatra.styling.colors.DATA_STYLES).

required

Returns:

Type Description

tuple[Figure, Axes, QuadContourSet]: The figure, the axes, and the contour set (the mappable the colorbar attaches to).

Raises:

Type Description
ValueError

If a coordinate has zero spread (via evaluate).

TypeError

If clip_path is an unsupported type (via the clip step).

Examples:

  • Filled contours add a colorbar by default:
    >>> import numpy as np
    >>> from cleopatra.glyphs.stats.kde_glyph import KDEGlyph
    >>> rng = np.random.default_rng(3)
    >>> x, y = rng.normal(size=300), rng.normal(size=300)
    >>> glyph = KDEGlyph(x, y, gridsize=40)
    >>> fig, ax, cs = glyph.plot()
    >>> glyph.cbar is not None
    True
    
  • Line contours (shade=False) and no colorbar:
    >>> import numpy as np
    >>> from cleopatra.glyphs.stats.kde_glyph import KDEGlyph
    >>> rng = np.random.default_rng(4)
    >>> x, y = rng.normal(size=300), rng.normal(size=300)
    >>> glyph = KDEGlyph(x, y, gridsize=40, shade=False)
    >>> fig, ax, cs = glyph.plot(add_colorbar=False)
    >>> glyph.cbar is None
    True
    
Source code in src/cleopatra/glyphs/stats/kde_glyph.py
def plot(
    self,
    ax: Axes | None = None,
    title: str | None = None,
    add_colorbar: bool | None = None,
    colorbar: bool | ColorBar | None = None,
    color: ColorScaling | None = None,
    contour: Contour | None = None,
    data_style: DataStyle | None = None,
):
    """Render the 2-D density as filled or line contours.

    Evaluates the KDE via `evaluate`, colours it through
    `_prepare_scalar_mapping`, and draws `contourf` (when `shade`) or
    `contour` (otherwise). An optional `clip_path` restricts the drawn
    contours.

    Args:
        ax: Axes to draw on. Falls back to the axes supplied at
            construction, otherwise a new figure/axes is created.
        title: Plot title. Overrides `default_options["title"]` when
            given.
        add_colorbar: Override the `add_colorbar` option for this call
            — True draws the colorbar, False suppresses it. Defaults to
            None, which keeps the value set at construction.
        colorbar: Typed `ColorBar` spec (or `True`/`False`/`None`) for the
            colorbar's placement, caption, and sizing; resolved into the
            `cbar_*` options. A `ColorBar`/`True` also enables the bar and is
            **sticky** -- it persists into later plots, overriding a
            construction-time `add_colorbar=False`; an explicit
            `add_colorbar=` argument still wins the on/off decision.
        hillshade: Relief-shade the density surface for this call (`True`
            or an options dict; see `cleopatra.glyphs.base.hillshade`). Defaults to
            None, which keeps the value set at construction. Accepting it
            here mirrors `ArrayGlyph.plot`/`MeshGlyph.plot`, so `hillshade`
            works the same way across all three glyphs.
        style: Name of a continuous `cleopatra.styling.colors.DATA_STYLES` preset
            to colour the density with (its cmap + norm; composes with
            `hillshade`). The preset name is **sticky** -- once set it
            persists into `default_options` and survives later plain
            `plot()` calls (like `ArrayGlyph`), and `self.style` reads it
            back; the resolved cmap is not persisted, so it never leaks.
            Not passing `style` keeps the current preset; passing
            `style=None` clears it back to the plain density colouring
            (unlike `hillshade`, which reverts to its construction value).
            A categorical preset has no meaning for a continuous density
            and raises `ValueError`. Valid names:
            `sorted(cleopatra.styling.colors.DATA_STYLES)`.

    Returns:
        tuple[Figure, Axes, QuadContourSet]: The figure, the axes, and
            the contour set (the mappable the colorbar attaches to).

    Raises:
        ValueError: If a coordinate has zero spread (via `evaluate`).
        TypeError: If `clip_path` is an unsupported type (via the clip
            step).

    Examples:
        - Filled contours add a colorbar by default:
            ```python
            >>> import numpy as np
            >>> from cleopatra.glyphs.stats.kde_glyph import KDEGlyph
            >>> rng = np.random.default_rng(3)
            >>> x, y = rng.normal(size=300), rng.normal(size=300)
            >>> glyph = KDEGlyph(x, y, gridsize=40)
            >>> fig, ax, cs = glyph.plot()
            >>> glyph.cbar is not None
            True

            ```
        - Line contours (`shade=False`) and no colorbar:
            ```python
            >>> import numpy as np
            >>> from cleopatra.glyphs.stats.kde_glyph import KDEGlyph
            >>> rng = np.random.default_rng(4)
            >>> x, y = rng.normal(size=300), rng.normal(size=300)
            >>> glyph = KDEGlyph(x, y, gridsize=40, shade=False)
            >>> fig, ax, cs = glyph.plot(add_colorbar=False)
            >>> glyph.cbar is None
            True

            ```
    """
    # Snapshot every option key the group objects will touch before
    # merging, so an invalid preset rolls back the WHOLE merge -- not
    # just style -- so a co-passed color=/contour= cannot leak into a
    # later plain plot.
    prev_group_opts = self._snapshot_group_options(color, contour, data_style)
    self._merge_group_params(color, contour, data_style)

    if ax is not None:
        self.ax = ax
        self.fig = _root_figure(ax)
    elif self.ax is None:
        self.fig, self.ax = self.create_figure_axes()
    ax = self.ax
    opts = self.default_options

    if title is not None:
        opts["title"] = title
    opts.update(_resolve_colorbar(colorbar))
    draw_colorbar = opts["add_colorbar"] if add_colorbar is None else add_colorbar

    gx, gy, density = self.evaluate()
    level_edges = self._resolve_levels(density)
    norm, cbar_kw, _ = self._prepare_scalar_mapping(density)
    cmap = resolve_colormap(opts["cmap"])

    style = opts.get("style")
    if style is not None:
        try:
            _, cfg = resolve_single_layer_style(style)
            if cfg.get("categories") is not None:
                raise ValueError(
                    f"data style {style!r} is categorical; KDEGlyph colours "
                    "a continuous density, so only continuous presets apply"
                )
        except ValueError:
            for key, value in prev_group_opts.items():
                opts[key] = value
            raise
        cfg = {
            **cfg,
            **{k: opts[k] for k in ("vmin", "vmax") if opts.get(k) is not None},
        }
        cmap = resolve_colormap(cfg["cmap"])
        norm, _, _ = resolve_style_norm(np.asarray(density, dtype=float), cfg)
        # Drop the linear ticks so the colorbar matches the preset norm.
        cbar_kw.pop("ticks", None)

    hillshade = resolve_hillshade(opts.get("hillshade"))
    if hillshade is not None:
        hs_norm = (
            norm
            if norm is not None
            else Normalize(vmin=float(density.min()), vmax=float(density.max()))
        )
        rgba = shade_grid(density, cmap, norm=hs_norm, **hillshade)
        extent = (
            float(gx.min()),
            float(gx.max()),
            float(gy.min()),
            float(gy.max()),
        )
        mappable = ax.imshow(rgba, extent=extent, origin="lower", aspect="auto")
        self._apply_clip(mappable)
        self.im = mappable
        if draw_colorbar:
            proxy = ScalarMappable(norm=hs_norm, cmap=cmap)
            proxy.set_array(density)
            self.cbar = self.create_color_bar(ax, proxy, cbar_kw)
        if opts["title"]:
            ax.set_title(opts["title"], fontsize=opts["title_size"])
        return self.fig, ax, mappable

    render = ax.contourf if opts["shade"] else ax.contour
    contour_set = render(gx, gy, density, levels=level_edges, cmap=cmap, norm=norm)
    self._apply_clip(contour_set)
    self.im = contour_set

    if draw_colorbar:
        self.cbar = self.create_color_bar(ax, contour_set, cbar_kw)

    if opts["title"]:
        ax.set_title(opts["title"], fontsize=opts["title_size"])

    return self.fig, ax, contour_set

Examples#

Filled density contours#

import numpy as np
from cleopatra.glyphs.stats.kde_glyph import KDEGlyph

rng = np.random.default_rng(0)
x = rng.normal(0, 1, 500)
y = rng.normal(0, 1, 500)

kde = KDEGlyph(x, y)
fig, ax, cs = kde.plot(title="Density")

Line contours and a wider kernel#

# shade=False -> line contours; bw_method > 1 widens the kernel
kde = KDEGlyph(x, y, shade=False, bw_method=1.5, levels=12)
fig, ax, cs = kde.plot()