Skip to content
Feeding a clanker? Grab this page as raw .md →

PairPlot API#

PairPlot shows every pair of numeric columns as a scatter plot, with a histogram of each column on the diagonal. Select rows in one cell and they light up in all the others.

import numpy as np
from wigglystuff import PairPlot

X = np.random.default_rng(0).normal(size=(1_000_000, 8))
labels = np.random.default_rng(1).integers(0, 5, size=len(X))

plot = PairPlot(X, color=labels)
plot

DataFrames work too. color can then be a column name, and the other numeric columns become the dimensions (pick them with columns=):

PairPlot(df, color="species")
PairPlot(df, columns=["bill_length", "bill_depth", "body_mass"], color="species")

Selecting rows#

  • Drag inside any scatter cell to draw a box, or switch to Lasso in the toolbar (or pass mode="lasso") for a freehand outline.
  • Drag a selection to move it. Every cell and histogram updates while you drag; Python receives the new selection when you let go.
  • Selected rows keep their category color; everything else turns gray. The diagonal shows the selection stacked on top of the full histogram.
  • Click outside the selection, double-click, or press Clear to clear it.

The selection is synced as a small description in data coordinates, not as a list of row indices, so it stays cheap at a million rows:

{"type": "box", "x_index": 0, "y_index": 1,
 "x_min": -1.2, "x_max": 0.8, "y_min": -0.5, "y_max": 2.1}
{"type": "lasso", "x_index": 0, "y_index": 1, "vertices": [[x, y], ...]}

Python evaluates it against the full data, so these are exact:

plot.selected_count    # number of selected rows
plot.get_mask()        # boolean NumPy mask
plot.get_indices()     # integer row indices
plot.selected_data     # selected rows, same type as the input

Keep, Exclude, Reset#

Like the parallel coordinates widget, a selection can become a filter:

  • Keep drops every row outside the selection.
  • Exclude drops every row inside it.
  • Reset brings every row back.

After a filter the axes and histograms animate to the rows that remain, which works as a zoom. Filters stack and are synced as the filters list, which Python replays to get exact results. The same actions exist in Python:

plot.keep()               # or plot.exclude()
plot.get_active_mask()    # rows left after the filters
plot.active_data          # those rows, same type as the input
plot.reset()

New selections only count rows that are still in the plot.

How it scales#

The numbers go to the browser once, as binary float32, and stay in a Web Worker. WebGL draws a sample of at most max_marks points spread across the cells (with 8 dimensions, about 36,000 per cell), while counts, histograms and selections use every row. At a million rows a selection takes a few tens of milliseconds, so a box keeps up while you drag it. Point size and opacity adapt to how crowded a cell is unless you set point_size.

Non-finite values are skipped in the axes, histograms and selections. Up to eight categories get distinct colors by default; pass color_map to choose your own.

Bases: AnyWidget

Interactive scatterplot matrix with linked box and lasso selection.

PairPlot sends numeric data to the browser as binary float32 data and keeps the full matrix in a Web Worker. The visible plot is bounded by a global mark budget, while a selection is evaluated against every row.

Parameters:

Name Type Description Default
data Any

A two-dimensional NumPy array, pandas DataFrame, or polars DataFrame containing numeric dimensions.

required
columns Sequence[str] | None

Labels for NumPy columns, or the DataFrame columns to display. NumPy columns default to x0, x1, ...; DataFrames default to every numeric column except a named color column.

None
color Any

Optional categorical values with one entry per row. For a DataFrame this may instead be the name of a color column.

None
color_map Mapping[Any, str] | None

Optional mapping from category values to CSS colors.

None
layout str

"full" or "lower".

'full'
mode str

Selection tool, "box" or "lasso". A box can be dragged to a new spot after it is drawn; both are switchable in the widget.

'box'
cell_size int | None

Size of each matrix cell in CSS pixels. None (the default) fits the matrix to the available width.

None
max_marks int

Approximate maximum number of point marks rendered across all visible off-diagonal cells.

2000000
point_size float | None

Point diameter in CSS pixels. None (the default) picks a size and opacity from how many points share a cell.

None
show_filters bool

Show the Keep, Exclude, Clear and Reset buttons. Turn it off when the plot is only for looking: clicking empty space still clears the selection, and :meth:keep, :meth:exclude and :meth:reset still work from Python.

True
show_mode_toggle bool

Show the Box / Lasso toggle. Without it the tool stays whatever mode says.

True
show_layout_toggle bool

Show the Full / Lower toggle. Without it the matrix stays whatever layout says.

True

The selection is synced as a compact data-coordinate description (box corners or lasso vertices). Use :meth:get_mask, :meth:get_indices, or :attr:selected_data to obtain exact Python-side results without syncing a potentially huge index list.

Like HiPlot, a selection can be turned into a filter: Keep drops every row outside it and Exclude drops every row inside it, after which the axes rescale to the rows that remain. Filters stack, are synced as the replayable filters list, and Reset removes them all. The same actions are available from Python as :meth:keep, :meth:exclude, and :meth:reset; :attr:active_data holds the rows that survive the filters.

Source code in wigglystuff/pair_plot.py
def __init__(
    self,
    data: Any,
    *,
    columns: Sequence[str] | None = None,
    color: Any = None,
    color_map: Mapping[Any, str] | None = None,
    layout: str = "full",
    mode: str = "box",
    cell_size: int | None = None,
    max_marks: int = 2_000_000,
    point_size: float | None = None,
    show_filters: bool = True,
    show_mode_toggle: bool = True,
    show_layout_toggle: bool = True,
    **kwargs: Any,
) -> None:
    matrix, names, color_values, source_kind = _normalize_input(
        data, columns=columns, color=color
    )
    codes, category_labels, palette = _encode_colors(
        color_values, color_map, matrix.shape[0]
    )

    self._source_data = data
    self._source_kind = source_kind
    self._matrix = matrix
    self._color_codes = codes

    super().__init__(
        columns=names,
        categories=category_labels,
        palette=palette,
        layout=layout,
        mode=mode,
        cell_size=cell_size,
        max_marks=max_marks,
        point_size=point_size,
        show_filters=show_filters,
        show_mode_toggle=show_mode_toggle,
        show_layout_toggle=show_layout_toggle,
        **kwargs,
    )
    self.on_msg(self._handle_frontend_message)

active_data property #

active_data

Return the rows left after Keep/Exclude filters, like the input type.

selected_data property #

selected_data

Return selected rows in the same broad container type as the input.

shape property #

shape: tuple[int, int]

The numeric matrix shape as (rows, dimensions).

clear_selection #

clear_selection() -> None

Clear the selection and restore the unfiltered color display.

Source code in wigglystuff/pair_plot.py
def clear_selection(self) -> None:
    """Clear the selection and restore the unfiltered color display."""
    self.selection = {}
    self.selected_count = 0

exclude #

exclude() -> None

Drop the rows inside the current selection (a no-op without one).

Source code in wigglystuff/pair_plot.py
def exclude(self) -> None:
    """Drop the rows inside the current selection (a no-op without one)."""
    self._add_filter("exclude")

get_active_mask #

get_active_mask()

Return a boolean NumPy mask of the rows left after all filters.

Source code in wigglystuff/pair_plot.py
def get_active_mask(self):
    """Return a boolean NumPy mask of the rows left after all filters."""
    import numpy as np

    active = np.ones(self._matrix.shape[0], dtype=bool)
    for step in self.filters:
        inside = self._selection_mask(step.get("selection", {}))
        if step.get("action") == "keep":
            active &= inside
        elif step.get("action") == "exclude":
            active &= ~inside
    return active

get_indices #

get_indices()

Return exact integer row indices inside the current selection.

Source code in wigglystuff/pair_plot.py
def get_indices(self):
    """Return exact integer row indices inside the current selection."""
    import numpy as np

    return np.flatnonzero(self.get_mask())

get_mask #

get_mask()

Return an exact boolean NumPy mask for the current selection.

Only rows that survive the Keep/Exclude filters can be selected.

Source code in wigglystuff/pair_plot.py
def get_mask(self):
    """Return an exact boolean NumPy mask for the current selection.

    Only rows that survive the Keep/Exclude filters can be selected.
    """
    mask = self._selection_mask(self.selection)
    if self.filters and mask.any():
        mask &= self.get_active_mask()
    return mask

keep #

keep() -> None

Keep only the rows inside the current selection (a no-op without one).

Source code in wigglystuff/pair_plot.py
def keep(self) -> None:
    """Keep only the rows inside the current selection (a no-op without one)."""
    self._add_filter("keep")

reset #

reset() -> None

Remove every Keep/Exclude filter and clear the selection.

Source code in wigglystuff/pair_plot.py
def reset(self) -> None:
    """Remove every Keep/Exclude filter and clear the selection."""
    self.filters = []
    self.clear_selection()

set_data #

set_data(data: Any, *, columns: Sequence[str] | None = None, color: Any = None, color_map: Mapping[Any, str] | None = None) -> None

Replace the displayed dataset and clear the current selection.

Source code in wigglystuff/pair_plot.py
def set_data(
    self,
    data: Any,
    *,
    columns: Sequence[str] | None = None,
    color: Any = None,
    color_map: Mapping[Any, str] | None = None,
) -> None:
    """Replace the displayed dataset and clear the current selection."""
    matrix, names, color_values, source_kind = _normalize_input(
        data, columns=columns, color=color
    )
    codes, category_labels, palette = _encode_colors(
        color_values, color_map, matrix.shape[0]
    )

    self._source_data = data
    self._source_kind = source_kind
    self._matrix = matrix
    self._color_codes = codes
    self.columns = names
    self.categories = category_labels
    self.palette = palette
    self.filters = []
    self.clear_selection()
    self.status = "loading"
    self.error = ""
    # Each view answers a data_version bump with a "ready" message, which
    # makes Python send the new buffers.
    self.data_version += 1

Synced traitlets#

Traitlet Type Notes
selection dict Current box or lasso in data coordinates, or {}.
selected_count int Exact number of selected rows (after filters).
filters list[dict] Keep/Exclude steps: {"action": "keep" \| "exclude", "selection": {...}}.
mode str Selection tool: "box" or "lasso".
layout str "full" matrix or "lower" triangle.
columns list[str] Dimension names.
categories list[str] Color categories, in sorted order.
palette list[str] One CSS color per category.
cell_size int \| None Cell size in pixels; None fits the available width.
point_size float \| None Point diameter in pixels; None adapts to density.
max_marks int Budget of points drawn across all cells.
status str "loading", "ready" or "error".
error str Error message from the browser, if any.