Source code for spacr.qt.widgets.grouped_plot

"""Render data-backed grouped comparisons with interchangeable graph types.

A :class:`PlotSpec` retains the source frame, group and value columns,
observation unit, and selected graph type. Retaining these data allows the
widget to redraw compatible graph types and export the figure together with
its source rows and statistical comparison.
"""
from __future__ import annotations

import logging
from dataclasses import dataclass, field, replace
from typing import Any, Dict, List, Optional, Sequence, Tuple

import numpy as np
import pandas as pd

from .fast_plots import FastPlot

LOG = logging.getLogger("spacr.qt.grouped_plot")

#: Map public graph-type names to marks accepted by
#: :meth:`~spacr.qt.widgets.fast_plots.FastPlot.add_group_mark`.
MARKS: Dict[str, str] = {
    "bar": "bar",
    "bar_jitter": "jitter_bar",
    "box_jitter": "jitter_box",
    "jitter": "jitter",
    "box": "box",
    "violin": "violin",
    "line": "line",
    "scatter": "points",
}


@dataclass
[docs] class PlotSpec: """Data and display metadata required to render a grouped plot. The retained frame supports graph-type compatibility checks, redrawing, data export, and statistical comparison without reconstructing the plot from rendered graphics. :param frame: the table holding the observations, normally a pandas DataFrame with ``value`` and optionally ``group`` columns; ``None`` gives no groups. :param value: name of the column plotted on the value axis; its values are converted to numbers and non-numeric entries dropped. """ frame: Any value: str group: str = "" kind: str = "" title: str = "" x_label: str = "" y_label: str = "" #: Experimental unit represented by one observation, such as ``well``, #: ``cell``, or ``guide``. Exported statistics record this unit explicitly. unit: str = "observation" #: Colour per group, when the user has chosen one. colours: Dict[str, str] = field(default_factory=dict) #: The group the figure is ABOUT, if any. #: #: THE HOUSE STYLE IS ONE ARGUMENT, NOT A RAINBOW: "everything is grey #: except what the sentence is about; a box per group in a different #: colour is a rainbow, not an argument". Naming a group here paints it #: in the highlight role and everything else in the data grey. Left #: empty, the groups take the categorical scale -- because a figure that #: has not been told what it is about has no subject to single out, and #: greying every group equally would leave a picture with no ink in it. highlight: str = "" #: A group that is by definition NOT the argument. #: #: The residual population -- "the rest", the unselected wells, the #: background -- is what the coloured groups are being compared #: AGAINST, so it takes the data grey whether or not a subject has been #: named. Naming it is the other half of the same rule: the ink goes on #: the claim, and this is the thing the claim is measured against. background: str = "" #: What a bar's whisker MEANS, from #: :data:`spacr.figures.spread.SPREAD_CHOICES`. #: #: SD describes the observations, SEM the confidence in their mean, and #: at n=3000 they differ by a factor of fifty-five -- so a reader who #: assumes the wrong one reads a real effect as noise or noise as a real #: effect. The choice is the user's and the caption names it. spread: str = "sem"
[docs] def shape(self) -> str: """The data shape, for deciding which kinds fit.""" from ...graph_types import shape_of return shape_of(self.frame, self.group, self.value)
[docs] def default_kind(self) -> str: """What this data is born as, when no kind is named.""" from ...graph_types import default_for return default_for(self.shape())
[docs] def groups(self) -> Dict[str, np.ndarray]: """Return ``{label: values}`` while preserving frame order. Preserving first occurrence keeps deliberately ordered experimental conditions, including controls, in their configured display order. """ frame = self.frame if frame is None or self.value not in getattr(frame, "columns", ()): return {} if not self.group or self.group not in frame.columns: values = pd.to_numeric(frame[self.value], errors="coerce").dropna() return {self.value: values.to_numpy(dtype=float)} out: Dict[str, np.ndarray] = {} for label in frame[self.group].astype(str): if label in out: continue part = frame.loc[frame[self.group].astype(str) == label, self.value] out[label] = pd.to_numeric(part, errors="coerce").dropna( ).to_numpy(dtype=float) return out
[docs] class GroupedPlot(FastPlot): """A pyqtgraph plot of a :class:`PlotSpec`, redrawable as any kind. :ivar spec: what is drawn. Assign through :meth:`show_spec`. :param spec: what to draw. ``None`` builds an empty plot; assign later through :meth:`show_spec` rather than by setting :attr:`spec`. :param parent: parent widget. """ def __init__(self, spec: Optional[PlotSpec] = None, parent=None, **kwargs): """Create the grouped plot, optionally drawing a spec straight away. :param spec: the plot to draw; ``None`` leaves the plot empty. :param parent: parent widget, or ``None``. :param kwargs: passed through to the base plot. """ super().__init__(parent=parent, **kwargs) self.spec: Optional[PlotSpec] = None if spec is not None: self.show_spec(spec)
[docs] def show_spec(self, spec: PlotSpec) -> int: """Draw ``spec``. Returns the number of groups drawn. :param spec: the data and display metadata to draw; an empty ``kind`` is replaced by the default kind for the data's shape. """ self.spec = spec kind = str(spec.kind or spec.default_kind()) self.spec = replace(spec, kind=kind) return self._draw()
[docs] def show_as(self, kind: str) -> int: """Redraw the retained data using graph type ``kind``. :param kind: graph-type name to draw; it must fit the retained data's shape (see :mod:`spacr.graph_types`). :raises ValueError: for a kind this data cannot support. Drawing it would otherwise imply a relationship unsupported by the available data shape. """ if self.spec is None: raise ValueError("this plot holds no data to redraw") from ...graph_types import fits, why_not shape = self.spec.shape() if not fits(shape, kind): raise ValueError(why_not(shape, kind)) return self.show_spec(replace(self.spec, kind=kind))
def _draw(self) -> int: """Draw the spec and return how many groups were drawn. A scatter or line over a numeric grouping column is drawn as two continuous axes instead: forcing it through the grouped mark would put every point at one categorical position -- a jitter under another name, which the graph-type table refuses to offer. The group size goes on the axis label, not only in the caption: a three-point group and a three-hundred-point group are the same bar, and the label is the only place a reader meets the difference before reading the sentence underneath. :returns: the number of groups drawn; ``0`` when there was nothing. """ from .fast_plots import colour_for spec = self.spec self._reset_scene() groups = spec.groups() if not groups: self.set_status("This table holds nothing to draw.") return 0 mark = MARKS.get(str(spec.kind), "jitter_bar") if str(spec.kind) in ("scatter", "line") and spec.group \ and spec.group in getattr(spec.frame, "columns", ()) \ and pd.api.types.is_numeric_dtype(spec.frame[spec.group]): return self._draw_xy(mark) labels = list(groups) subject = str(spec.highlight or "") background = str(spec.background or "") for position, label in enumerate(labels): colour = spec.colours.get(label) or self._ink_for( label, position, subject, background) self.add_group_mark(float(position), groups[label], mark, colour=colour, seed=position, spread=str(getattr(spec, "spread", "sem"))) self.plot.getAxis("bottom").setTicks( [[(position, f"{label}\n(n={len(groups[label]):,})") for position, label in enumerate(labels)]]) self._label_axes(spec, categorical=True) self.set_status(self._caption(groups)) return len(labels) @staticmethod def _ink_for(label: str, position: int, subject: str = "", background: str = ""): """The colour one group's mark is drawn in. The house style is one argument, not a rainbow. With a subject named, that group takes the highlight role and every other group is the data grey. A background group is grey either way -- it is what the others are being compared against, so it is never the claim. With neither named there is no claim to make, and the categorical scale is what tells the groups apart. """ from .fast_plots import colour_for try: from ...figures.style import ROLES except Exception: # noqa: BLE001 return colour_for(position) if background and str(label) == background: return ROLES["data"] if not subject: return colour_for(position) return ROLES["highlight"] if str(label) == subject else ROLES["data"] def _draw_xy(self, mark: str) -> int: """Two continuous axes: a scatter, or a line through ordered x.""" spec = self.spec frame = spec.frame.dropna(subset=[spec.group, spec.value]) x = pd.to_numeric(frame[spec.group], errors="coerce").to_numpy(float) y = pd.to_numeric(frame[spec.value], errors="coerce").to_numpy(float) if mark == "line": import pyqtgraph as pg from .fast_plots import colour_for order = np.argsort(x) self.plot.plot(x[order], y[order], pen=pg.mkPen(colour_for(0), width=2)) else: self.add_scatter(x, y) self._label_axes(spec, categorical=False) self.set_status(f"{len(x):,} point(s).") return 1 def _label_axes(self, spec, *, categorical: bool) -> None: """Write the axis labels and the title from the spec. :param spec: the plot spec to read labels from. :param categorical: whether the horizontal axis is categorical, which decides what the fallback bottom label is. """ self.plot.setLabel("left", spec.y_label or spec.value) self.plot.setLabel( "bottom", spec.x_label or (spec.group if not categorical else spec.group or "")) if spec.title: self.plot.setTitle(spec.title) def _caption(self, groups) -> str: """The sample count for every group, and what the whisker means. AN ERROR BAR WITH AN UNNAMED SPREAD IS NOT READABLE. A reader cannot tell a SEM from an SD without being told, and the two differ by sqrt(n) -- so wherever a whisker is drawn the sentence under the plot says which quantity it is. """ counts = "; ".join(f"{label} n={len(values):,}" for label, values in groups.items()) spec = self.spec mark = MARKS.get(str(getattr(spec, "kind", "") or ""), "jitter_bar") if mark not in ("bar", "jitter_bar"): return counts from ...figures.spread import SPREAD_NONE, spread_label spread = str(getattr(spec, "spread", "") or SPREAD_NONE) if spread == SPREAD_NONE: return counts said = spread_label(spread) return f"{counts} — {said}" if counts else said
[docs] def comparison_groups(self) -> Optional[dict]: """Return grouped values for export-time statistical comparison. Returns ``None`` unless the retained specification contains at least two groups. """ if self.spec is None: return None groups = self.spec.groups() return groups if len(groups) >= 2 else None
[docs] def comparison_unit(self) -> str: """What one point on this plot IS -- a well, a field, an object. NAMED RATHER THAN ASSUMED, because it decides what a comparison means: the same data compared per well and per object gives different p-values, and only one of them answers the question asked. :returns: the unit's name, ``observation`` when unspecified. """ return self.spec.unit if self.spec is not None else "observation"
[docs] def export_settings(self) -> dict: """The parent's export settings, plus this plot's grouping. :returns: the settings dict. """ out = super().export_settings() if self.spec is not None: out.update(kind=self.spec.kind, group=self.spec.group, value=self.spec.value, unit=self.spec.unit, shape=self.spec.shape()) return out
[docs] def frame(self): """Return the source rows exported to ``data.csv``.""" if self.spec is not None and self.spec.frame is not None: return self.spec.frame return super().frame()