Source code for plotnine_extra.positions.position_quasirandom

"""
Position adjustment that uses quasi-random jittering,
ported from R's ``ggbeeswarm::position_quasirandom``.
"""

from __future__ import annotations

from copy import deepcopy
from typing import TYPE_CHECKING

import numpy as np
from plotnine._utils import groupby_apply, resolution
from plotnine.positions.position import position

from ._beeswarm_algorithms import offset_quasirandom

_VALID_METHODS = {"quasirandom", "pseudorandom"}

if TYPE_CHECKING:
    from typing import Optional

    import pandas as pd
    from plotnine.iapi import pos_scales


[docs] class position_quasirandom(position): """ Jitter points using quasi-random noise to reduce overplotting Uses density-aware quasi-random jittering (van der Corput sequence) so that the point cloud reflects the underlying data distribution, similar to a violin plot but with individual points. Parameters ---------- method : ``"quasirandom"`` (default) uses a van der Corput sequence; ``"pseudorandom"`` uses uniform random jitter. width : Maximum jitter width. If ``None``, ``0.4 * resolution`` of the data axis is used. varwidth : If ``True``, scale the width of each group proportionally to its size relative to the largest group. bandwidth : Bandwidth adjustment for the internal kernel density estimate. Values < 1 yield a tighter fit. nbins : Number of bins for density estimation (passed through but currently unused; bandwidth controls smoothing). dodge_width : Amount by which to dodge groups that share the same position. ``None`` means no dodging. """ REQUIRED_AES = {"x", "y"} def __init__( self, method: str = "quasirandom", width: Optional[float] = None, varwidth: bool = False, bandwidth: float = 0.5, nbins: Optional[int] = None, dodge_width: Optional[float] = None, ): if method not in _VALID_METHODS: msg = ( f"method must be one of {sorted(_VALID_METHODS)}, " f"got {method!r}" ) raise ValueError(msg) self.params = { "method": method, "width": width, "varwidth": varwidth, "bandwidth": bandwidth, "nbins": nbins, "dodge_width": dodge_width, }
[docs] def setup_params(self, data: pd.DataFrame) -> dict: params = deepcopy(self.params) if params["width"] is None: params["width"] = resolution(data["x"]) * 0.4 return params
[docs] @classmethod def compute_panel( cls, data: pd.DataFrame, scales: pos_scales, params: dict, ) -> pd.DataFrame: dodge_width = params.get("dodge_width") if dodge_width is not None: data = _dodge_groups(data, dodge_width) total_count = len(data) if params["varwidth"] else None def _jitter_group(gdf: pd.DataFrame) -> pd.DataFrame: gdf = gdf.copy() y = gdf["y"].to_numpy(dtype=np.float64) offsets = offset_quasirandom( y, method=params["method"], width=params["width"], bandwidth=params["bandwidth"], nbins=params["nbins"], varwidth=params["varwidth"], group_count=len(gdf), total_count=total_count, ) gdf["x"] = gdf["x"] + offsets return gdf return groupby_apply(data, "group", _jitter_group)
def _dodge_groups(data: "pd.DataFrame", dodge_width: float) -> "pd.DataFrame": """ Spread aesthetic groups horizontally so they do not overlap. """ data = data.copy() groups = data["group"].unique() n_groups = len(groups) if n_groups <= 1: return data offsets = np.linspace(-dodge_width / 2, dodge_width / 2, n_groups) group_map = dict(zip(sorted(groups), offsets)) data["x"] = data["x"] + data["group"].map(group_map) return data