diff --git a/CHANGELOG.md b/CHANGELOG.md index cc40e602..f68732c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,55 @@ change. Versions absent from the list were never released. ## [Unreleased] ### Breaking +- `annotated_heatmap` returns one `PlotSpec` instead of a `gggrid` of + `SupPlotsSpec` panels. Annotation tracks are ggside side layers + (`geom_xsidetile`/`geom_ysidetile`), each with its own fill scale + (`new_scale_fill`) and legend, so they zoom and pan with the heatmap in HTML + output (`interactive=True`). Track titles beside the bands are hidden by + default, since the legends name each track; the new `track_titles=True` + shows them (a top or bottom band's names on its right, a left or right + band's names above it). The group dendrogram is drawn in + the observation band, outermost. `layers_all` acts as `layers` now that there + is a single plot. `column_annotation_height`, `row_annotation_width` and the + tree's 0.15 width set the bands' share of the panel + (`ggside_panel_scale_x`/`_y`). + - Migration: index the plot's `as_dict()["layers"]` instead of + `["figures"]`; combine it with other plots through `gggrid` as any + `PlotSpec`. +- ggrust 0.6 is required. `cellestial.setup_html` is removed, with the ggrust + `LetsPlot` shim it aliased; it was a no-op, as is the setup call cellestial + made on import in IPython. + - Migration: delete `setup_html()` calls; use `ggrust.display("html" | "svg" | "png")` + to choose notebook output. +- `dotplot`, `stacked_violin`, `matrixplot` and `heatmap(aggregate=True)` draw + the group dendrogram with ggrust's axis dendrogram (`scale_y_dendro` + + `guide_axis_dendro`) instead of a `geom_path` layer. The tree sits in a fixed + band right of the panel rather than inside an extended x range, so the data + area no longer shrinks to make room for it. `dendrogram_kwargs` is removed from + these functions and from `heatmap`; the per-cell `heatmap(aggregate=False)` + keeps its path layer, styled by `dendrogram_color` and `dendrogram_size`. + - Migration: drop `dendrogram_kwargs`; style the tree with `dendrogram_color` + and `dendrogram_size`, or with `theme(axis_ticks_y=element_line(...))`. +- `arrow_axis` returns a ggrust theme with arrow axis lines instead of a deferred + layer of `geom_path`/`geom_segment`. It no longer takes `plot=` (the theme needs + no data) or `**geom_kwargs`; `linetype=` is now an explicit parameter. `length` + is a fraction of the panel rather than of the data range, so arrows start at the + panel corner. + - Migration: drop `plot=`; pass `linetype=` directly; other geom kwargs such as + `alpha` are no longer supported. +- `bracket` computes its tests with ggrust's `stat_compare_means` instead of + scipy. P-values, the `none`/`bonferroni`/`fdr_bh` corrections and the stars + agree for groups of 50 or more values; below that, tie-free groups get R's + exact Wilcoxon test, so `mannwhitney` p-values change there. Stars now use + `<=` cutoffs, and p-value labels show 2 significant digits. Bracket tips are + 3% of the y range (ggpubr's default) instead of a fixed size. Removed: + `test="student"`, `alternative`, `label="padj"` and label sequences, + `label_format`, `prefix`, `prefix_style`, `separator` and `threshold`. + - Migration: `label="padj"` becomes `label="pvalue"` (it shows the adjusted + p-value); `threshold=0.05` becomes `hide_ns=True` with `label="stars"`; + pass `tip_length=` for other tips. One-sided and pooled-variance tests are + no longer available: ggrust's `student` is Welch's test, so it is not offered + under that name; use `test="ttest"`. - `spatialdata` is no longer installed by default. It is only needed to plot `SpatialData` objects with `spatial`, `spatials` or `build_frame`; AnnData inputs, spatial ones included, work without it. Dropping it from the core @@ -23,7 +72,8 @@ change. Versions absent from the list were never released. in code that adds layers, scales or themes to cellestial plots. - `bracket(test="ttest")` is now Welch's t-test rather than the pooled-variance one, matching R's `t.test()` and scanpy. P-values change. - - Migration: pass `test="student"`, a new option, for the previous behaviour. + - Migration: none; the pooled-variance test is no longer available (see the + `stat_compare_means` entry above). - `volcano` and `volcanos` name their p-value column after the values it holds. Under the default `use_adjusted_pvalue=True` the column, tooltip entry and y-axis title read `pvalue_adj` and `-log10(Padj)`; raw p-values are unaffected. diff --git a/README.md b/README.md index 763181d7..cdc8a86b 100644 --- a/README.md +++ b/README.md @@ -177,8 +177,6 @@ cl.boxplot( ) + scale_fill_hue() + cl.bracket( y_padding=0.2, label="pvalue", - prefix="p", - prefix_style="<", comparisons=[ ("Lymphocytes", "Monocytes"), ("Monocytes", "Erythroid"), diff --git a/SKILLS/dendrogram.md b/SKILLS/dendrogram.md index 3035b8f3..62bc9321 100644 --- a/SKILLS/dendrogram.md +++ b/SKILLS/dendrogram.md @@ -2,83 +2,53 @@ How dendrograms are wired into the heatmap variants (`heatmap`, `matrixplot`, `dotplot`, `stacked_violin`). Use this as the template when adding a dendrogram to a new plot. -## Helpers - -Two private helpers live in [cellestial/util/dendrogram.py](../cellestial/util/dendrogram.py) and are re-exported from [cellestial.util](../cellestial/util/__init__.py): +## Two cases -- `_get_dendrogram(data, group_by) -> (categories_ordered, paths)` - - Reads `data.uns[f"dendrogram_{group_by}"]`, computes via `scanpy.tl.dendrogram` if missing. - - `categories_ordered`: list of group labels in dendrogram leaf order. **Use this as the y order.** - - `paths`: Polars DataFrame with normalized `x` (leaf positions 0..n_groups-1) and `y` (dcoord scaled to 0..1), plus `group` id per linkage segment. +- **One row per group** (dotplot, stacked_violin, matrixplot, heatmap with `aggregate=True`): use ggrust's native axis dendrogram. The tree orders the y-axis and draws itself right of the panel. +- **Per-cell rows** (heatmap with `aggregate=False`; `complex/annotated_heatmap.py`): groups span many rows of unequal height, so a discrete dendrogram scale cannot express them. These keep the hand-drawn tree: `geom_path` in the heatmap, two-point `geom_xsideline`/`geom_ysideline` segments in the annotated heatmap's observation side band (outermost, beyond the tracks). -- `_get_dendrogram_path_frame(paths, *, n_x, n_groups, group_centers, dendrogram_ratio=0.15) -> pl.DataFrame` - - Maps the normalized paths into actual plot coordinates on the **right side**, along the y-axis. - - Width of dendrogram = `n_x * dendrogram_ratio`, anchored at `x = n_x - 0.5`. - - `group_centers` is the y-pixel center of each group, ordered to match `categories_ordered`. +## Helpers -## Required parameters to expose +All live in [cellestial/util/dendrogram.py](../cellestial/util/dendrogram.py), re-exported from [cellestial.util](../cellestial/util/__init__.py). Each reads `data.uns[use_key or f"dendrogram_{group_by}"]` and computes it with `scanpy.tl.dendrogram` if missing. -```python -dendrogram: bool = False, -dendrogram_color: str = "black", -dendrogram_size: float = 0.5, -dendrogram_kwargs: dict | None = None, -``` +- `_get_dendrogram_tree(data, group_by, *, use_key=None) -> ClusterTree`: scanpy's linkage as a ggrust tree; `tree.ordered_labels` is scanpy's `categories_ordered`. +- `_dendrogram_y_axis(tree, *, limits, fixed=False)`: `scale_y_dendro(..., guide=guide_axis_dendro(side="opposite"))` plus a coord holding the y `limits`. Pass `fixed=True` for `geom_raster` plots, which default to `coord_fixed()`. +- `_dendrogram_line_theme(*, color, size)`: the tree is drawn with the y tick line, so this styles `axis_ticks_y` (size in geom units) and sets the tick length to 0. Add it **after** any full theme (`theme_classic()` in `_THEME_DOTPLOT` resets it). +- Per-cell only: `_get_dendrogram(...) -> (categories_ordered, paths)` and `_get_dendrogram_path_frame(paths, *, n_x, n_groups, group_centers)`, which map scipy's `icoord`/`dcoord` beside the panel. -## Wiring (three steps) +## Wiring a one-row-per-group plot -**1. Determine y order before building positions.** The dendrogram dictates group order; everything else (positions, separator lines, group bars) must follow. +Keep numeric `position_y` values (group `i` of the tree order at `i`): numeric layers such as key-group brackets and the border `geom_rect` still work on the discrete dendrogram scale. ```python if dendrogram: - y_order_groups, paths = _get_dendrogram(data, group_by) + tree = _get_dendrogram_tree(data, group_by, use_key=dendrogram_key) + y_order_groups = list(tree.ordered_labels) +... +if dendrogram: + plot += _dendrogram_y_axis(tree, limits=[-0.5, y_max_limit]) else: - y_order_groups = frame.select(group_by).unique(maintain_order=True)[group_by].cast(pl.String).to_list() - paths = None -``` - -**2. After computing layout, build the path frame and add `geom_path`.** You need `n_x`, `n_groups`, and `group_centers` (the y-coordinate of each group's center, in `y_order_groups` order). - -```python + plot += scale_y_continuous(breaks=list(range(n_y)), labels=y_order_groups, + limits=[-0.5, y_max_limit], expand=[0, 0]) +... +plot += _THEME_DOTPLOT if dendrogram: - dendro_frame = _get_dendrogram_path_frame( - paths, n_x=n_x, n_groups=len(y_order_groups), group_centers=group_centers, - ) - plot += geom_path( - data=dendro_frame, - mapping=aes(x="x", y="y", group="group"), - color=dendrogram_color, - size=dendrogram_size, - **(dendrogram_kwargs or {}), - ) + plot += _dendrogram_line_theme(color=dendrogram_color, size=dendrogram_size) ``` -**3. Extend the x-axis limit to include the dendrogram width.** The dendrogram lives at `x in [n_x - 0.5, n_x - 0.5 + n_x*dendrogram_ratio]`. If you set explicit `scale_x_continuous(limits=...)` or have a bordering `geom_rect`, account for this: +No x limit needs to make room for the tree; it lives in its own band outside the panel. + +## Parameters to expose ```python -x_max_limit = n_x - 0.5 -if dendrogram: - x_max_limit = dendro_frame["x"].max() -plot += scale_x_continuous(breaks=list(range(n_x)), labels=x_keys, limits=[-0.5, x_max_limit], expand=[0, 0]) +dendrogram: bool = False, +dendrogram_color: str = "black", +dendrogram_size: float = 0.5, +dendrogram_key: str | None = None, ``` -If a border rectangle frames the data area, draw it from `-0.5` to `n_x - 0.5` so the dendrogram sits **outside** the frame (see dotplot pattern). - -## Axis type - -The plot must use **continuous numeric** axes (not discrete), because the dendrogram lives at fractional x-coordinates beyond the data area. Pattern: - -- Encode positions as floats (`position_x`, `position_y`). -- Use `scale_x_continuous(breaks=list(range(n_x)), labels=x_keys)` and analogous y scale to fake categorical labels on a continuous axis. -- Always pass `limits=[...]` and `expand=[0, 0]` when a dendrogram is present, so it isn't clipped. - -## Computing `group_centers` - -- **Aggregated row-per-group plots** (matrixplot, dotplot, stacked_violin, heatmap with `aggregate=True`): `group_centers = [float(i) for i in range(n_groups)]`. -- **Per-cell rows** (heatmap with `aggregate=False`): compute the mean `position_y` of cells in each group, sorted by group order. See `_assign_positions` in [heatmap.py](../cellestial/single/heatmap/heatmap.py) for the canonical implementation. - ## Reference implementations -- [cellestial/single/heatmap/heatmap.py](../cellestial/single/heatmap/heatmap.py) (per-cell and aggregated) -- [cellestial/single/heatmap/dotplot.py](../cellestial/single/heatmap/dotplot.py) (with bordering rectangle) +- [cellestial/single/heatmap/dotplot.py](../cellestial/single/heatmap/dotplot.py) (with bordering rectangle and key-group brackets) - [cellestial/single/heatmap/stacked_violin.py](../cellestial/single/heatmap/stacked_violin.py) +- [cellestial/single/heatmap/heatmap.py](../cellestial/single/heatmap/heatmap.py) (native when aggregated, path for per-cell rows) diff --git a/cellestial/__init__.py b/cellestial/__init__.py index e21eccab..f082cd60 100644 --- a/cellestial/__init__.py +++ b/cellestial/__init__.py @@ -1,7 +1,3 @@ -__lazy_modules__ = ["ggrust"] - -from ggrust import LetsPlot - from cellestial import datasets from cellestial._version import __version__, versions from cellestial.complex import annotated_heatmap @@ -65,8 +61,6 @@ show_colors, ) -setup_html = LetsPlot.setup_html - __all__ = [ "BLUE", "CHERRY", @@ -115,7 +109,6 @@ "ridges", "save", "scatter", - "setup_html", "show_colors", "spatial", "spatials", @@ -133,13 +126,3 @@ "xyplot", "xyplots", ] - -try: - from IPython.core.interactiveshell import InteractiveShell - - if InteractiveShell.initialized(): - from ggrust import LetsPlot - - LetsPlot.setup_html() -except ImportError: - pass diff --git a/cellestial/complex/annotated_heatmap.py b/cellestial/complex/annotated_heatmap.py index f2a9d7aa..61aef5cc 100644 --- a/cellestial/complex/annotated_heatmap.py +++ b/cellestial/complex/annotated_heatmap.py @@ -11,21 +11,26 @@ from ggrust import ( aes, coord_cartesian, - element_blank, element_text, - geom_path, geom_raster, geom_segment, geom_tile, - gggrid, + geom_xsideline, + geom_xsidetile, + geom_ysideline, + geom_ysidetile, ggplot, + ggside, ggtb, + new_scale_fill, scale_fill_brewer, scale_fill_gradient, scale_fill_gradientn, scale_fill_manual, scale_x_continuous, + scale_xsidey_discrete, scale_y_continuous, + scale_ysidex_discrete, theme, ) from mudata import MuData @@ -41,13 +46,13 @@ from collections.abc import Sequence from ggrust.plot.core import FeatureSpec, PlotSpec - from ggrust.plot.subplots import SupPlotsSpec AnnotationColors = Mapping[str, str] | Sequence[str] -# Width of the dendrogram panel, relative to the heatmap. +# Width of the dendrogram, relative to the heatmap. _DENDROGRAM_WIDTH = 0.15 + # ColorBrewer palettes cycled across annotation tracks so each gets a distinct one. # Qualitative palettes come first (better for categorical tracks), then diverging. _BREWER_PALETTES = ( @@ -70,9 +75,7 @@ "RdYlGn", ) -# Legend element sizing. Must live on the subplots that own each legend: the grid -# theme only controls legend placement/arrangement, not the collected element sizes. -# `title` carries the bold: lets-plot resolves a legend title's `face` from the +# Legend element sizing. `title` carries the bold: lets-plot resolves a legend title's `face` from the # parent `title` element, so setting `face` on `legend_title` alone has no effect. # Axis titles are blanked here, so this only bolds the (legend) titles. _LEGEND_THEME = theme( @@ -240,107 +243,86 @@ def _annotation_scale( return scale_fill_manual(values=colors, name=series.name) -def _blank_strip_theme(*, legend: bool) -> FeatureSpec: - """Void theme shared by annotation tracks, labels, and the dendrogram panel.""" - return ( - _THEME_HEATMAP - + theme( - axis_title=element_blank(), - axis_text=element_blank(), - axis_ticks=element_blank(), - axis_line=element_blank(), - legend_position="bottom" if legend else "none", - ) - + _LEGEND_THEME - ) - - -def _annotation_strip( +def _track_layers( frame: pl.DataFrame, + annotations: Sequence[str], *, - position: str, - value: str, - horizontal: bool, - legend: bool, - colors: AnnotationColors | None, - palette: str, - position_limits: tuple[float, float], -) -> PlotSpec: + band: Literal["x", "y"], + far: bool, + colors: Mapping[str, AnnotationColors], + palette_of: Mapping[str, str], + titles: bool, +) -> list[FeatureSpec]: """ - Build a single one-row (or one-column) annotation track. + Build one band's annotation tracks, each with its own fill scale. Notes ----- - `horizontal` strips vary along x (column annotations); vertical strips vary - along y (row annotations). `position_limits` pin the varying axis so the - track aligns with the heatmap panel. `palette` is the ColorBrewer palette used - for a categorical track when `colors` is not given. + `frame` holds the band's position column (shared with the heatmap axis) and + the annotation columns. Each track is a level of the band's discrete cross + axis, named after its column. The first listed is the outermost, so on a + `far` band (right or top, where the cross axis grows away from the heatmap) + the level order is reversed. Without `titles` the band axis has no breaks, + so the track names take no room. """ - constant = "_strip" - frame = frame.with_columns(pl.lit(0).alias(constant)) - mapping = ( - aes(x=position, y=constant, fill=value) - if horizontal - else aes(x=constant, y=position, fill=value) - ) - plot = ggplot(frame) + geom_tile(mapping) - scale = _annotation_scale(frame[value], colors, palette) - if scale is not None: - plot += scale - if horizontal: - plot += scale_x_continuous(limits=list(position_limits), expand=[0, 0]) - plot += scale_y_continuous(expand=[0, 0]) - else: - plot += scale_x_continuous(expand=[0, 0]) - plot += scale_y_continuous(limits=list(position_limits), expand=[0, 0]) - plot += _blank_strip_theme(legend=legend) - return plot + side_tile = geom_xsidetile if band == "x" else geom_ysidetile + layers: list[FeatureSpec] = [] + for annotation in annotations: + layers.append(new_scale_fill()) + layers.append(side_tile(aes(fill=annotation), data=frame)) + layers.append( + _annotation_scale(frame[annotation], colors.get(annotation), palette_of[annotation]) + ) + if annotations: + limits = list(reversed(annotations)) if far else list(annotations) + # Track names on the right of an x band, clear of the heatmap's y axis + # labels, and on top of a y band: turned names at the bottom would make the + # bottom axis area as tall as the longest name. Hidden names keep the band + # axis on the main axes' sides, where its line joins theirs. + if band == "x": + names = {"position": "right"} if titles else {"breaks": []} + layers.append(scale_xsidey_discrete(limits=limits, expand=[0, 0], **names)) + else: + names = {"position": "top"} if titles else {"breaks": []} + layers.append(scale_ysidex_discrete(limits=limits, expand=[0, 0], **names)) + return layers -def _dendrogram_panel( +def _dendrogram_layer( paths: pl.DataFrame, *, + band: Literal["x", "y"], group_centers: list[float], - n_groups: int, - position_limits: tuple[float, float], + leaf_edge: float, + extent: float, color: str, size: float, - side: Literal["left", "right", "top", "bottom"], kwargs: dict, -) -> PlotSpec: +) -> FeatureSpec: """ - Build the group dendrogram as a panel on one `side` of the heatmap. + Draw the group dendrogram as line segments in a side band. Notes ----- - Leaves sit at the group centers (height 0, next to the tracks); the root - extends away from the heatmap toward `side`. `position_limits` match the - heatmap observation axis so leaves line up with the group blocks. On a - left/right side the height runs along x and leaves along y; on a top/bottom - side the height runs along y and leaves along x. + Leaves sit at the group centers along the heatmap's observation axis, at + cross position `leaf_edge` next to the tracks; the root lies `extent` cross + units away (negative `extent` points toward the left or bottom). Each path + is split into two-point segments, since a side line orders its points along + the shared axis. """ - leaf_positions = np.arange(n_groups, dtype=float) + leaf_positions = np.arange(len(group_centers), dtype=float) leaf = np.interp(paths["x"].to_numpy(), leaf_positions, group_centers) - height = paths["y"].to_numpy() - # Root extends away from the heatmap: negative toward left/bottom, positive - # toward right/top. `expand` keeps a small margin so the root is not clipped. - if side in ("left", "right"): - signed_height = -height if side == "left" else height - frame = pl.DataFrame({"x": signed_height, "y": leaf, "group": paths["group"]}) - height_scale = scale_x_continuous(expand=[0.05, 0]) - leaf_scale = scale_y_continuous(limits=list(position_limits), expand=[0, 0]) + height = leaf_edge + paths["y"].to_numpy() * extent + path_group = paths["group"].to_numpy() + starts = np.flatnonzero(path_group[:-1] == path_group[1:]) + segment = np.repeat(np.arange(starts.size), 2) + index = np.column_stack([starts, starts + 1]).ravel() + frame = pl.DataFrame({"leaf": leaf[index], "height": height[index], "segment": segment}) + if band == "x": + mapping, side_line = aes(x="leaf", y="height", group="segment"), geom_xsideline else: - signed_height = height if side == "top" else -height - frame = pl.DataFrame({"x": leaf, "y": signed_height, "group": paths["group"]}) - height_scale = scale_y_continuous(expand=[0.05, 0]) - leaf_scale = scale_x_continuous(limits=list(position_limits), expand=[0, 0]) - return ( - ggplot(frame) - + geom_path(aes("x", "y", group="group"), color=color, size=size, **kwargs) - + height_scale - + leaf_scale - + _blank_strip_theme(legend=False) - ) + mapping, side_line = aes(x="height", y="leaf", group="segment"), geom_ysideline + return side_line(mapping, data=frame, inherit_aes=False, color=color, size=size, **kwargs) def annotated_heatmap( @@ -371,6 +353,7 @@ def annotated_heatmap( row_annotation_width: float = 0.025, column_annotation_position: Literal["top", "bottom"] = "top", row_annotation_position: Literal["left", "right"] = "left", + track_titles: bool = False, color_low: str = "#0000ff", color_mid: str = "#ffffff", color_high: str = "#ff0000", @@ -382,7 +365,7 @@ def annotated_heatmap( interactive: bool = False, legend: bool = True, **geom_kwargs, -) -> SupPlotsSpec: +) -> PlotSpec: """ Annotated heatmap. @@ -417,9 +400,8 @@ def annotated_heatmap( single spec or a sequence of them. A `scale_fill_*` (e.g. `scale_fill_viridis()`) overrides the default value gradient. layers_all : FeatureSpec | Sequence[FeatureSpec] | None, default=None - Extra lets-plot layers to add to every subplot (the heatmap, each - annotation track, and the dendrogram panel), unlike `layers` which only - affects the heatmap; a single spec or a sequence of them. + Extra lets-plot layers added after `layers`. The heatmap and its tracks + are one plot, so this now acts as `layers`; kept for compatibility. scale_axis : {0, 1} | None, default=None Standardize values with min-max scaling. If 0, standardize each variable (column); if 1, standardize each observation (row). @@ -429,7 +411,8 @@ def annotated_heatmap( their data: variable tracks to the left, observation tracks and the dendrogram above the heatmap. dendrogram : bool, default=False - Whether to cluster the groups and draw a dendrogram panel to the left. + Whether to cluster the groups and draw a dendrogram beside the + observation tracks, outermost in their side band. The clustering is computed if not already available. Requires `group_by`. group_lines : bool, default=True Whether to draw horizontal separator lines between groups. @@ -459,6 +442,11 @@ def annotated_heatmap( Which side of the heatmap the row (observation) tracks and the dendrogram sit on. Under `transpose` this reflects to the top/bottom side (left to top, right to bottom). + track_titles : bool, default=False + Whether to name each annotation track beside its band. Hidden by + default, since the legends already name the tracks; set to `True` to + show them (a top or bottom band's names on its right, a left or right + band's names above it). color_low : str, default='#0000ff' Color for low values in the heatmap gradient. color_mid : str, default='#ffffff' @@ -487,8 +475,8 @@ def annotated_heatmap( Returns ------- - SupPlotsSpec - The composite annotated heatmap. + PlotSpec + The annotated heatmap, one plot with its tracks in side bands. Raises ------ @@ -500,8 +488,10 @@ def annotated_heatmap( Notes ----- Rows are observations and columns are the variable `keys`. Variable tracks - sit above the heatmap and observation tracks to its left. The panels are - composed with `gggrid`, each keeping its own legend. + sit above the heatmap and observation tracks to its left, in ggside side + bands that share the heatmap's axes (and its zoom, with `interactive`). + Each track has its own fill scale (`new_scale_fill`) and legend, and is + labelled on its band's axis. When `dendrogram=True` and the requested dendrogram is absent from `data.uns`, plot construction calls `scanpy.tl.dendrogram` and stores the @@ -533,7 +523,6 @@ def annotated_heatmap( column_annotations = _as_list(column_annotations) row_annotations = _as_list(row_annotations) annotation_colors = annotation_colors or {} - shared_layers = _as_layers(layers_all) if dendrogram and group_by is None: msg = "`group_by` is required when `dendrogram` is set." @@ -594,33 +583,36 @@ def annotated_heatmap( # Axis roles. `position_x` holds the variable-key index, `position_y` the # observation index; `transpose` swaps which is drawn on the visual x-axis. - # The variable axis carries the tick labels; the observation axis is blanked. + # The variable axis carries the tick labels; the observation axis has no breaks + # (a theme blank would also hide the side band's track names on that axis). if transpose: heatmap_x, heatmap_y = "position_y", "position_x" key_scale = scale_y_continuous( breaks=list(range(n_keys)), labels=keys, limits=list(key_limits), expand=[0, 0] ) - observation_scale = scale_x_continuous(limits=list(observation_limits), expand=[0, 0]) - blank_observation_axis = theme(axis_text_x=element_blank(), axis_ticks_x=element_blank()) + observation_scale = scale_x_continuous( + breaks=[], limits=list(observation_limits), expand=[0, 0] + ) else: heatmap_x, heatmap_y = "position_x", "position_y" key_scale = scale_x_continuous( breaks=list(range(n_keys)), labels=keys, limits=list(key_limits), expand=[0, 0] ) - observation_scale = scale_y_continuous(limits=list(observation_limits), expand=[0, 0]) - blank_observation_axis = theme(axis_text_y=element_blank(), axis_ticks_y=element_blank()) + observation_scale = scale_y_continuous( + breaks=[], limits=list(observation_limits), expand=[0, 0] + ) # MAIN heatmap. `raster` renders the cells as one image (fast for many cells); - # `tile` draws individual cells and supports tooltips. Aspect stays controllable - # through the grid widths/heights. - aes_main = aes(heatmap_x, heatmap_y, fill=value_column, **mapping.as_dict()) + # `tile` draws individual cells and supports tooltips. The plot maps only the + # positions, which the side tracks inherit along the shared axis. + aes_main = aes(fill=value_column, **mapping.as_dict()) geom_layer = ( geom_raster(aes_main, **geom_kwargs) if geom == "raster" else geom_tile(aes_main, **geom_kwargs) ) - htmp = ( - ggplot(frame) + plot = ( + ggplot(frame, aes(heatmap_x, heatmap_y)) + geom_layer + _fill_gradient( frame[value_column], @@ -629,22 +621,11 @@ def annotated_heatmap( color_high=color_high, midpoint=midpoint, ) - + key_scale - + observation_scale - # `geom_raster` otherwise locks to square pixels (collapsing a few-column, - # many-row heatmap); `coord_cartesian` frees the aspect for the grid to set. - + coord_cartesian() - + _THEME_HEATMAP - + blank_observation_axis - + theme(legend_position="bottom" if legend else "none") - + _LEGEND_THEME ) - if interactive: - htmp += ggtb(size_zoomin=-1) - # Extra user layers for the heatmap, a single spec or a sequence. A `scale_fill_*` - # here overrides the default value gradient (lets-plot keeps the last scale). - htmp = _apply_layers(htmp, _as_layers(layers)) - htmp = _apply_layers(htmp, shared_layers) + # Extra user layers, a single spec or a sequence, added before the tracks' new + # fill scales so a `scale_fill_*` here overrides the default value gradient. + plot = _apply_layers(plot, _as_layers(layers)) + plot = _apply_layers(plot, _as_layers(layers_all)) # GROUP separator lines between contiguous blocks, spanning the variable axis. if group_lines and group_order is not None and len(group_order) > 1: @@ -656,22 +637,24 @@ def annotated_heatmap( boundaries.append(n_observations - running - 0.5) if transpose: lines = pl.DataFrame({"x": boundaries}) - htmp += geom_segment( + plot += geom_segment( data=lines, mapping=aes(x="x", xend="x"), y=-0.5, yend=n_keys - 0.5, + inherit_aes=False, color=group_lines_color, size=group_lines_size, **(group_lines_kwargs or {}), ) else: lines = pl.DataFrame({"y": boundaries}) - htmp += geom_segment( + plot += geom_segment( data=lines, mapping=aes(y="y", yend="y"), x=-0.5, xend=n_keys - 0.5, + inherit_aes=False, color=group_lines_color, size=group_lines_size, **(group_lines_kwargs or {}), @@ -679,8 +662,8 @@ def annotated_heatmap( # Resolve which side each annotation group sits on. `transpose` reflects the # requested position across the main diagonal (left<->top, right<->bottom), - # since it swaps which axis is horizontal. A top/bottom side means the track - # is a horizontal strip; a left/right side means a vertical strip. + # since it swaps which axis is horizontal. A top/bottom side is the x side + # band (sharing the x axis); a left/right side is the y side band. reflect: dict[str, Literal["left", "right", "top", "bottom"]] = { "left": "top", "right": "bottom", @@ -698,8 +681,9 @@ def annotated_heatmap( for index, name in enumerate([*column_annotations, *row_annotations]) } - # COLUMN annotation tracks (variable metadata), drawn along the variable axis. - column_strips: list[PlotSpec] = [] + # COLUMN annotation tracks (variable metadata), in the band along the variable axis. + variable_band: Literal["x", "y"] = "x" if variable_side in ("top", "bottom") else "y" + variable_layers: list[FeatureSpec] = [] if column_annotations: variables = build_frame( data=data, @@ -709,37 +693,33 @@ def annotated_heatmap( ).filter(pl.col(variable_id_column).is_in(keys)) variables = variables.with_columns( pl.col(variable_id_column).replace_strict(position_x).alias("position_x") + ).select("position_x", *column_annotations) + variable_layers = _track_layers( + variables, + column_annotations, + band=variable_band, + far=variable_side in ("right", "top"), + colors=annotation_colors, + palette_of=palette_of, + titles=track_titles, ) - for annotation in column_annotations: - strip = _annotation_strip( - variables.select("position_x", annotation), - position="position_x", - value=annotation, - horizontal=variable_side in ("top", "bottom"), - legend=legend, - colors=annotation_colors.get(annotation), - palette=palette_of[annotation], - position_limits=key_limits, - ) - column_strips.append(_apply_layers(strip, shared_layers)) - - # ROW annotation tracks (observation metadata), drawn along the observation axis. - row_strips: list[PlotSpec] = [] - for annotation in row_annotations: - strip = _annotation_strip( - observations.select("position_y", annotation), - position="position_y", - value=annotation, - horizontal=observation_side in ("top", "bottom"), - legend=legend, - colors=annotation_colors.get(annotation), - palette=palette_of[annotation], - position_limits=observation_limits, - ) - row_strips.append(_apply_layers(strip, shared_layers)) - # DENDROGRAM panel (group clustering), drawn alongside the observation axis. - dendrogram_panel = None + # ROW annotation tracks (observation metadata), in the band along the observation axis. + observation_band: Literal["x", "y"] = "y" if variable_band == "x" else "x" + observation_far = observation_side in ("right", "top") + observation_layers = _track_layers( + observations.select("position_y", *row_annotations), + row_annotations, + band=observation_band, + far=observation_far, + colors=annotation_colors, + palette_of=palette_of, + titles=track_titles, + ) + + # DENDROGRAM (group clustering), outermost in the observation band. Groups span + # many rows, so an axis dendrogram (one leaf per discrete level) cannot be used. + observation_thickness = row_annotation_width * len(row_annotations) if dendrogram: assert group_by is not None assert dendrogram_paths is not None @@ -748,111 +728,49 @@ def annotated_heatmap( pl.col("position_y").mean().alias("_center") ) center_of = dict(zip(centers[group_by].cast(pl.String), centers["_center"], strict=True)) - group_centers = [center_of[group] for group in group_order] - dendrogram_panel = _apply_layers( - _dendrogram_panel( + # The tree spans `_DENDROGRAM_WIDTH` of the heatmap; cross units are tracks. + extent = _DENDROGRAM_WIDTH / row_annotation_width + leaf_edge = len(row_annotations) - 0.5 if observation_far else -0.5 + observation_layers.append( + _dendrogram_layer( dendrogram_paths, - group_centers=group_centers, - n_groups=len(group_order), - position_limits=observation_limits, + band=observation_band, + group_centers=[center_of[group] for group in group_order], + leaf_edge=leaf_edge, + extent=extent if observation_far else -extent, color=dendrogram_color, size=dendrogram_size, - side=observation_side, kwargs=dendrogram_kwargs or {}, - ), - shared_layers, - ) - - # COMPOSE: the observation group (dendrogram outermost, then observation - # tracks) and the variable group (variable tracks). One group flanks the - # heatmap horizontally (left/right side), the other vertically (top/bottom), - # since their axes are perpendicular. First listed track is the outermost; - # each keeps its per-track thickness. - dendrogram_thickness = [_DENDROGRAM_WIDTH] if dendrogram_panel is not None else [] - observation_panels = ([dendrogram_panel] if dendrogram_panel is not None else []) + row_strips - observation_thickness = dendrogram_thickness + [row_annotation_width] * len(row_strips) - variable_thickness = [column_annotation_height] * len(column_strips) - if observation_side in ("left", "right"): - flank_panels, flank_thickness, flank_side = ( - observation_panels, - observation_thickness, - observation_side, - ) - stack_panels, stack_thickness, stack_side = ( - column_strips, - variable_thickness, - variable_side, - ) - else: - flank_panels, flank_thickness, flank_side = ( - column_strips, - variable_thickness, - variable_side, - ) - stack_panels, stack_thickness, stack_side = ( - observation_panels, - observation_thickness, - observation_side, + ) ) + observation_thickness += _DENDROGRAM_WIDTH - # Heatmap row: flank tracks sit to the left or right of the heatmap; on the - # right the first-listed (outermost) track ends up farthest out, so reverse. - if flank_side == "left": - row_cells, widths, heatmap_column = ( - [*flank_panels, htmp], - [*flank_thickness, 1.0], - len(flank_panels), - ) - else: - row_cells = [htmp, *reversed(flank_panels)] - widths = [1.0, *reversed(flank_thickness)] - heatmap_column = 0 - n_columns = len(row_cells) - - def _stack_row(panel: PlotSpec) -> list[PlotSpec | None]: - """A grid row holding one stacked track in the heatmap column, else empty.""" - row: list[PlotSpec | None] = [None] * n_columns - row[heatmap_column] = panel - return row - - # Stack tracks sit above or below the heatmap; reverse for bottom so the - # first-listed (outermost) track ends up farthest out. - if stack_side == "top": - top_panels, top_thickness, bottom_panels, bottom_thickness = ( - stack_panels, - stack_thickness, - [], - [], - ) - else: - top_panels, top_thickness = [], [] - bottom_panels, bottom_thickness = ( - list(reversed(stack_panels)), - list(reversed(stack_thickness)), + # COMPOSE: both bands in one plot, each sized by its tracks (and the tree). + thickness = { + variable_band: column_annotation_height * len(column_annotations), + observation_band: observation_thickness, + } + plot = _apply_layers(plot, variable_layers + observation_layers) + plot += ( + key_scale + + observation_scale + # `geom_raster` otherwise locks to square pixels (collapsing a few-column, + # many-row heatmap); `coord_cartesian` frees the aspect. + + coord_cartesian() + + ggside( + x_pos=variable_side if variable_band == "x" else observation_side, + y_pos=variable_side if variable_band == "y" else observation_side, ) - - plots: list[PlotSpec | None] = [] - for panel in top_panels: - plots.extend(_stack_row(panel)) - plots.extend(row_cells) - for panel in bottom_panels: - plots.extend(_stack_row(panel)) - - heights = [*top_thickness, 1.0, *bottom_thickness] - grid = gggrid( - plots, - ncol=n_columns, - widths=widths, - heights=heights, - align=True, - guides="collect" if legend else None, # ty:ignore[invalid-argument-type] - ) - if legend: - # The grid only places/arranges the collected legends (one bottom row); their - # element sizes come from the subplots' `_LEGEND_THEME`, not from here. - grid += theme( - legend_position="bottom", + + _THEME_HEATMAP + + theme( + ggside_panel_scale_x=thickness["x"] or None, + ggside_panel_scale_y=thickness["y"] or None, + legend_position="bottom" if legend else "none", legend_box="horizontal", legend_direction="horizontal", ) - return grid + + _LEGEND_THEME + ) + if interactive: + plot += ggtb(size_zoomin=-1) + return plot diff --git a/cellestial/layers/arrow.py b/cellestial/layers/arrow.py index fab10829..b03d762b 100644 --- a/cellestial/layers/arrow.py +++ b/cellestial/layers/arrow.py @@ -4,154 +4,72 @@ from typing import TYPE_CHECKING, Literal -import polars as pl -from ggrust import ( - aes, - arrow, - element_blank, - element_text, - geom_blank, - geom_path, - geom_segment, - theme, -) - -from cellestial.layers._deferred import DeferredLayer -from cellestial.util import get_mapping -from cellestial.util.errors import MissingAestheticError +from ggrust import arrow, element_blank, element_line, element_text, geom_blank, theme if TYPE_CHECKING: - from ggrust.plot.core import FeatureSpecArray, PlotSpec - from polars import DataFrame + from ggrust.plot.core import FeatureSpec -# fraction of each axis arm drawn as the arrowhead-bearing tip segment; -# ggrust caps the arrowhead to ~half the segment length, so the tip must be -# long enough to render a full-size head -_TIP_FRACTION = 0.3 +# geom line widths are drawn at `size * 2.2` px, theme lines at `size` px; +# scaling keeps `size` in geom units so the arrows match the former layer version +_GEOM_LINE_UNIT = 2.2 -def _axis_arrow_layers( - frame: DataFrame, +def _arrow_theme( *, - x: str, - y: str, length: float, size: float, color: str, angle: float, + linetype: int | str | None = None, arrow_kwargs: dict | None = None, - geom_kwargs: dict | None = None, -) -> FeatureSpecArray: +) -> FeatureSpec: """ - Build the arrow-axis as an L-shaped path plus two arrowhead tip segments. + Build the arrow axis as theme axis lines with arrowheads. Parameters ---------- - frame : `polars.DataFrame` - DataFrame holding the `x` and `y` columns. - x : str - Name of the x axis column. - y : str - Name of the y axis column. length : float - Length of each axis arm as a fraction of its data range. + Span of each axis line as a fraction of the panel (0.25 covers 25%). size : float - Line width of the path and tip segments. + Width of the axis lines, in geom line-width units. color : str - Color of the path and tip segments. + Color of the axis lines. angle : float Angle of the arrowhead in degrees. - arrow_kwargs : dict | None - Additional parameters for the `arrow` layer. - geom_kwargs : dict | None - Additional parameters for the `geom_path` and `geom_segment` layers. + linetype : int | str | None, default=None + Line type of the axis lines, by number or name. + arrow_kwargs : dict | None, default=None + Additional parameters for `arrow`. Returns ------- - `FeatureSpecArray` - The path and tip-segment layers. - - Notes - ----- - The semi-square L is one continuous `geom_path` that stops short of each - open end so the corner joins cleanly with no overlap. Each tip is completed - by a `geom_segment` carrying the arrowhead, meeting the path end-to-end. + `FeatureSpec` + Theme feature specification. """ - arrow_kwargs = arrow_kwargs or {} - geom_kwargs = geom_kwargs or {} - - bounds = frame.select( - pl.col(x).min().alias("x_min"), - pl.col(x).max().alias("x_max"), - pl.col(y).min().alias("y_min"), - pl.col(y).max().alias("y_max"), - ).row(0, named=True) - if any(value is None for value in bounds.values()): - msg = "Arrow axes require at least one valid coordinate pair." - raise ValueError(msg) - - x_min = bounds["x_min"] - x_max = bounds["x_max"] - y_min = bounds["y_min"] - y_max = bounds["y_max"] - - # find total difference between the max and min for both axis - x_diff = x_max - x_min - y_diff = y_max - y_min - - # find the ends of the arrows - xend = x_min + length * x_diff - yend = y_min + length * y_diff - - # adjust bottom ends of arrows - adjust_rate = 0.025 - x0 = x_min - x_diff * adjust_rate - y0 = y_min - y_diff * adjust_rate - - # tip segments carry the arrowheads; the path stops short of each tip - tip_x = (xend - x0) * _TIP_FRACTION - tip_y = (yend - y0) * _TIP_FRACTION - - # semi-square L (stopping short of both tips), drawn as one connected path - path_frame = pl.DataFrame({"x": [xend - tip_x, x0, x0], "y": [y0, y0, yend - tip_y]}) - - layers = geom_path( - data=path_frame, - mapping=aes(x="x", y="y"), - color=color, - size=size, - **geom_kwargs, - ) - # X axis tip - layers += geom_segment( - x=xend - tip_x, - y=y0, - xend=xend, - yend=y0, + line = element_line( color=color, - size=size, - arrow=arrow(angle, **arrow_kwargs), - **geom_kwargs, + size=size * _GEOM_LINE_UNIT, + linetype=linetype, + arrow=arrow(angle, **(arrow_kwargs or {})), + extent=length, ) - # Y axis tip - layers += geom_segment( - x=x0, - y=yend - tip_y, - xend=x0, - yend=yend, - color=color, - size=size, - arrow=arrow(angle, **arrow_kwargs), - **geom_kwargs, + return theme( + # remove axis elements + axis_text_x=element_blank(), + axis_text_y=element_blank(), + axis_ticks_y=element_blank(), + axis_ticks_x=element_blank(), + # the default theme blanks `axis_line_y`, so set both lines explicitly + axis_line_x=line, + axis_line_y=line, + # position axis titles according to arrow size + axis_title_x=element_text(hjust=length / 2.5), # better than 2 + axis_title_y=element_text(hjust=length / 2.5), ) - return layers def _modify_axis( - frame: DataFrame, *, - x: str, - y: str, axis_type: Literal["axis", "arrow"] | None, arrow_size: float, arrow_color: str, @@ -163,12 +81,6 @@ def _modify_axis( Parameters ---------- - frame : `polars.DataFrame` - DataFrame copied from the single cell data. - x : str - Name of the x axis column. - y : str - Name of the y axis column. axis_type : {'axis', 'arrow'} | None Whether to use regular axis or arrows as the axis. arrow_size : float @@ -178,15 +90,12 @@ def _modify_axis( arrow_angle : float Angle of the arrow head in degrees. arrow_length : float - Span of each axis line as a fraction of its data range (0.25 covers 25%). + Span of each axis line as a fraction of the panel (0.25 covers 25%). Returns ------- `FeatureSpec` or `FeatureSpecArray` Theme feature specification. - - for more information on the arrow parameters, see: - https://lets-plot.org/python/pages/api/lets_plot.arrow.html """ if axis_type is None: return theme( @@ -202,21 +111,7 @@ def _modify_axis( return geom_blank() elif axis_type == "arrow": - new_layer = theme( - # remove axis elements - axis_text_x=element_blank(), - axis_text_y=element_blank(), - axis_ticks_y=element_blank(), - axis_ticks_x=element_blank(), - axis_line=element_blank(), - # position axis titles according to arrow size - axis_title_x=element_text(hjust=arrow_length / 2.5), # better than 2 - axis_title_y=element_text(hjust=arrow_length / 2.5), - ) - new_layer += _axis_arrow_layers( - frame, - x=x, - y=y, + return _arrow_theme( length=arrow_length, size=arrow_size, color=arrow_color, @@ -226,28 +121,21 @@ def _modify_axis( msg = f"expected 'axis' or 'arrow' for 'axis_type' argument, but received {axis_type}" raise ValueError(msg) - return new_layer - def arrow_axis( *, - plot: PlotSpec | None = None, size: float = 1, length: float = 0.25, angle: float = 10, color: str = "#3f3f3f", + linetype: int | str | None = None, arrow_kwargs: dict | None = None, - **geom_kwargs, -) -> DeferredLayer: +) -> FeatureSpec: """ - Layer of arrows as the X and Y axis to the plot. + Theme with arrows as the X and Y axis of the plot. Parameters ---------- - plot : PlotSpec | None, default=None - If provided, the arrow axis is built from this plot's data and aesthetics - regardless of which plot the resulting layer is added to. When `None`, - the layer is deferred and introspects the plot it is added to via `+`. size : float Width of the axis lines. color : str @@ -255,28 +143,20 @@ def arrow_axis( angle : float Angle of the arrow head in degrees. length : float - Span of each axis line as a fraction of its data range (0.25 covers 25%). + Span of each axis line as a fraction of the panel (0.25 covers 25%). + linetype : int | str | None, default=None + Line type of the axis lines, by number or name. E.g "dashed". arrow_kwargs : dict | None, default=None - Additional parameters for the `arrow` layer. E.g {"type": "closed"}. - For more information on arrow parameters, see: - https://lets-plot.org/python/pages/api/lets_plot.arrow.html - **geom_kwargs - Additional parameters for the `geom_path` and `geom_segment` layers. - For more information on geom_path parameters, see: - https://lets-plot.org/python/pages/api/lets_plot.geom_path.html + Additional parameters for `arrow`. E.g {"type": "closed"}. Returns ------- - DeferredLayer - - Raises - ------ - MissingAestheticError - If `x` or `y` cannot be inferred from the receiving plot. + `FeatureSpec` + Theme feature specification. Notes ----- - Uses `geom_path` to draw the axis lines and `geom_segment` for the arrowheads. + Uses ggrust arrow axes: `element_line(arrow=..., extent=...)` on the axis lines. Examples -------- @@ -323,42 +203,11 @@ def arrow_axis( umap + cl.arrow_axis(length=0.20,color="dark_violet") """ - explicit_plot = plot - - def _build(receiving_plot: PlotSpec) -> FeatureSpecArray: - source = explicit_plot if explicit_plot is not None else receiving_plot - frame = source.get_plot_shared_data() - mapping = get_mapping(source, index=0) - x = mapping.get("x") - y = mapping.get("y") - if x is None: - msg = "`x` is present neither as argument nor in the plot aesthetics." - raise MissingAestheticError(msg) - if y is None: - msg = "`y` is present neither as argument nor in the plot aesthetics." - raise MissingAestheticError(msg) - new_layer = theme( - # remove axis elements - axis_text_x=element_blank(), - axis_text_y=element_blank(), - axis_ticks_y=element_blank(), - axis_ticks_x=element_blank(), - axis_line=element_blank(), - # position axis titles according to arrow size - axis_title_x=element_text(hjust=length / 2.5), # better than 2 - axis_title_y=element_text(hjust=length / 2.5), - ) - new_layer += _axis_arrow_layers( - frame, - x=x, - y=y, - length=length, - size=size, - color=color, - angle=angle, - arrow_kwargs=arrow_kwargs, - geom_kwargs=geom_kwargs, - ) - return new_layer - - return DeferredLayer(_build) + return _arrow_theme( + length=length, + size=size, + color=color, + angle=angle, + linetype=linetype, + arrow_kwargs=arrow_kwargs, + ) diff --git a/cellestial/layers/bracket.py b/cellestial/layers/bracket.py index f66b67b2..32f2ffda 100644 --- a/cellestial/layers/bracket.py +++ b/cellestial/layers/bracket.py @@ -1,14 +1,12 @@ from __future__ import annotations -__lazy_modules__ = ["ggrust", "numpy", "scipy.stats"] +__lazy_modules__ = ["ggrust"] from itertools import combinations from typing import TYPE_CHECKING, Literal -import numpy as np import polars as pl -from ggrust import aes, geom_bracket -from scipy.stats import mannwhitneyu, ttest_ind +from ggrust import aes, stat_compare_means from cellestial.layers._deferred import DeferredLayer from cellestial.util import get_mapping, retrieve @@ -20,53 +18,17 @@ from ggrust.plot.core import FeatureSpec, LayerSpec, PlotSpec from polars import DataFrame +_TEST_METHODS = {"mannwhitney": "wilcox.test", "ttest": "t.test"} +_CORRECTION_METHODS = {"none": "none", "bonferroni": "bonferroni", "fdr_bh": "BH"} +_LABELS = {"stars": "p.signif", "pvalue": "p.format"} -def _correct_pvalues( - pvalues: np.ndarray, - method: Literal["none", "bonferroni", "fdr_bh"], -) -> np.ndarray: - """Adjust p-values for multiple testing.""" - if method == "none": - return pvalues - if method == "bonferroni": - return np.minimum(pvalues * len(pvalues), 1.0) - if method == "fdr_bh": - n = len(pvalues) - order = np.argsort(pvalues) - ranked = pvalues[order] - adjusted = ranked * n / np.arange(1, n + 1) - # enforce monotonicity from the largest p-value downwards - adjusted = np.minimum.accumulate(adjusted[::-1])[::-1] - adjusted = np.minimum(adjusted, 1.0) - result = np.empty_like(adjusted) - result[order] = adjusted - return result - msg = f"`correction` must be one of 'none', 'bonferroni', 'fdr_bh'. Received: {method!r}" - raise ValueError(msg) - - -_STAR_THRESHOLDS = ( - (0.0001, "****"), - (0.001, "***"), - (0.01, "**"), - (0.05, "*"), -) - - -def _significance_stars(pvalue: float) -> str: - """Convert a p-value into an asterisk significance label.""" - for threshold, stars in _STAR_THRESHOLDS: - if pvalue < threshold: - return stars - return "ns" - - -def _star_threshold(pvalue: float) -> float | None: - """Return the star-bracket upper bound for a p-value, or None if not significant.""" - for threshold, _ in _STAR_THRESHOLDS: - if pvalue < threshold: - return threshold - return None + +def _option(name: str, value: str, options: dict[str, str]) -> str: + """Translate a cellestial option into its `stat_compare_means` name.""" + if value not in options: + msg = f"`{name}` must be one of {list(options)}. Received: {value!r}" + raise ValueError(msg) + return options[value] def _expand_comparisons( @@ -117,26 +79,17 @@ def _add(group_a: str, group_b: str) -> None: return expanded -def _compute_bracket_frame( +def _bracket_layout( frame: DataFrame, *, x: str, y: str, comparisons: Sequence[Sequence[str]] | None, - test: Literal["mannwhitney", "ttest", "student"], - alternative: Literal["two-sided", "less", "greater"], - correction: Literal["none", "bonferroni", "fdr_bh"], - label: Literal["stars", "pvalue", "padj"] | Sequence[Literal["stars", "pvalue", "padj"]], - label_format: str, - prefix: str, - prefix_style: Literal["=", "<"] | None, - separator: str, - threshold: float | None, y_position: float | None, y_step: float | None, y_padding: float, -) -> DataFrame: - """Build a `geom_bracket` DataFrame from pairwise significance tests.""" +) -> tuple[DataFrame, list[tuple[str, str]], list[float]]: + """Return the finite rows, the group pairs and the bracket heights.""" finite_frame = frame.filter(pl.col(y).is_not_null() & pl.col(y).is_finite()) # determine which pairs to compare @@ -150,38 +103,13 @@ def _compute_bracket_frame( msg = "No group pairs available to compare." raise ValueError(msg) - # run the pairwise test - test_functions = { - "mannwhitney": lambda a, b: mannwhitneyu(a, b, alternative=alternative), - # Welch's, matching `t.test()` in R and scanpy's own `t-test` ranking. - "ttest": lambda a, b: ttest_ind(a, b, alternative=alternative, equal_var=False), - "student": lambda a, b: ttest_ind(a, b, alternative=alternative, equal_var=True), - } - if test not in test_functions: - msg = f"`test` must be one of {list(test_functions)}. Received: {test!r}" - raise ValueError(msg) - run_test = test_functions[test] - - records = [] - invalid_comparisons = [] - for group_a, group_b in pairs: - values_a = finite_frame.filter(pl.col(x) == group_a)[y].to_numpy() - values_b = finite_frame.filter(pl.col(x) == group_b)[y].to_numpy() - if len(values_a) < 2 or len(values_b) < 2: - invalid_comparisons.append( - f"{group_a!r} ({len(values_a)}) vs {group_b!r} ({len(values_b)})" - ) - continue - result = run_test(values_a, values_b) - records.append( - { - "xmin": group_a, - "xmax": group_b, - "statistic": float(result.statistic), - "pvalue": float(result.pvalue), - } - ) - + # every compared group needs at least 2 finite observations + counts = dict(finite_frame[x].value_counts().iter_rows()) + invalid_comparisons = [ + f"{group_a!r} ({counts.get(group_a, 0)}) vs {group_b!r} ({counts.get(group_b, 0)})" + for group_a, group_b in pairs + if counts.get(group_a, 0) < 2 or counts.get(group_b, 0) < 2 + ] if invalid_comparisons: prefix = ( "No valid group comparisons available. " @@ -195,90 +123,24 @@ def _compute_bracket_frame( ) raise ValueError(msg) - brackets = pl.DataFrame(records) - - # adjust for multiple testing - pvalue_adjusted = _correct_pvalues(brackets["pvalue"].to_numpy(), method=correction) - brackets = brackets.with_columns(pvalue_adj=pl.Series(pvalue_adjusted)) - - # filter by threshold - if threshold is not None: - pvalue_column = "pvalue_adj" if correction != "none" else "pvalue" - brackets = brackets.filter(pl.col(pvalue_column) <= threshold) - if len(brackets) == 0: - return brackets - - # build the label column - one component per label kind, merged row-wise - label_kinds = [label] if isinstance(label, str) else list(label) - valid_kinds = {"stars", "pvalue", "padj"} - invalid = [kind for kind in label_kinds if kind not in valid_kinds] - if invalid: - msg = ( - "`label` entries must be from 'stars', 'pvalue', 'padj'. " - f"Received invalid: {invalid!r}" - ) - raise ValueError(msg) - if len(label_kinds) == 0: - msg = "`label` must contain at least one entry." - raise ValueError(msg) - - pvalues_raw = brackets["pvalue"].to_list() - pvalues_adj = brackets["pvalue_adj"].to_list() - pvalues_for_stars = brackets["pvalue_adj" if correction != "none" else "pvalue"].to_list() - - def _format_pvalue(pvalue: float) -> str: - if prefix_style == "<": - threshold = _star_threshold(pvalue) - symbol, value = (">", 0.05) if threshold is None else ("<", threshold) - return f"{prefix} {symbol} {value:{label_format}}".lstrip() - if prefix_style == "=": - if not prefix: - return f"{pvalue:{label_format}}" - return f"{prefix} = {pvalue:{label_format}}" - if prefix_style is None: - return f"{prefix}{pvalue:{label_format}}" - msg = f"`prefix_style` must be '=', '<', or None. Received: {prefix_style!r}" - raise ValueError(msg) - - components = [] - for kind in label_kinds: - if kind == "stars": - components.append([_significance_stars(pvalue) for pvalue in pvalues_for_stars]) - elif kind == "pvalue": - components.append([_format_pvalue(pvalue) for pvalue in pvalues_raw]) - else: # "padj" - components.append([_format_pvalue(pvalue) for pvalue in pvalues_adj]) - - labels = [separator.join(parts) for parts in zip(*components, strict=True)] - brackets = brackets.with_columns(label=pl.Series(labels)) - - # compute y positions so brackets stack above the data without overlapping - + # stack the brackets above the data without overlapping data_min = finite_frame[y].min() data_max = finite_frame[y].max() data_range = data_max - data_min # ty:ignore[unsupported-operator] _y_position = data_max + data_range * y_padding if y_position is None else y_position _y_step = data_range * y_padding if y_step is None else y_step - positions = [_y_position + _y_step * index for index in range(len(brackets))] - brackets = brackets.with_columns(y=pl.Series(positions)) - - return brackets + positions = [_y_position + _y_step * index for index in range(len(pairs))] + return finite_frame, pairs, positions def bracket( *, plot: PlotSpec | None = None, comparisons: Sequence[Sequence[str]] | None = None, - test: Literal["mannwhitney", "ttest", "student"] = "mannwhitney", - alternative: Literal["two-sided", "less", "greater"] = "two-sided", + test: Literal["mannwhitney", "ttest"] = "mannwhitney", correction: Literal["none", "bonferroni", "fdr_bh"] = "fdr_bh", - label: Literal["stars", "pvalue", "padj"] - | Sequence[Literal["stars", "pvalue", "padj"]] = "stars", - label_format: str = ".3g", - prefix: str = "", - prefix_style: Literal["=", "<"] | None = "=", - separator: str = " ", - threshold: float | None = None, + label: Literal["stars", "pvalue"] = "stars", + hide_ns: bool = False, y_position: float | None = None, y_step: float | None = None, y_padding: float = 0.08, @@ -291,7 +153,7 @@ def bracket( **geom_kwargs, ) -> DeferredLayer: """ - Layer of `geom_bracket` annotating pairwise significance between groups. + Layer of `stat_compare_means` annotating pairwise significance between groups. Parameters ---------- @@ -305,47 +167,21 @@ def bracket( If None, every pair of groups present in the plot is compared. `"*"` on either side expands to all other groups, so `[("A", "*")]` compares `A` against every remaining group. - test : {'mannwhitney', 'ttest', 'student'}, default='mannwhitney' - Statistical test used for each pairwise comparison. - 'mannwhitney' is non-parametric and recommended for expression data. + test : {'mannwhitney', 'ttest'}, default='mannwhitney' + Two-sided statistical test used for each pairwise comparison, as in R. + 'mannwhitney' is the Wilcoxon rank-sum test, non-parametric and + recommended for expression data; it is exact below 50 tie-free values + per group (R's `wilcox.test`). 'ttest' is Welch's independent two-sample t-test (assumes normality). - 'student' is the pooled-variance variant, which also assumes both - groups share a variance. - alternative : {'two-sided', 'less', 'greater'}, default='two-sided' - The alternative hypothesis passed to the underlying test. correction : {'none', 'bonferroni', 'fdr_bh'}, default='fdr_bh' Multiple-testing correction applied across the pairwise p-values. - label : {'stars', 'pvalue', 'padj'} or sequence of those, default='stars' - The bracket label to draw. - 'stars' maps p-values to asterisks (`****` < 0.0001, `***` < 0.001, - `**` < 0.01, `*` < 0.05, `ns` otherwise). - 'pvalue' prints the raw p-value and 'padj' prints the adjusted p-value. - A sequence merges each component with a newline, e.g. - `label=("stars", "padj")` stacks the stars on top of the adjusted p-value. - label_format : str, default='.3g' - Format string used when `label` contains 'pvalue' or 'padj'. - prefix : str, default='' - Text prepended to the numeric p-value/padj labels. Ignored for the - 'stars' component. When `prefix_style` is '=' or '<', the symbol is - inserted between `prefix` and the value with whitespace around it, - so `prefix='p'` with `prefix_style='='` renders `'p = 0.001'`. - When `prefix_style` is None, `prefix` is used as-is, so the caller - is responsible for including any symbol and spacing. - prefix_style : {'=', '<'} or None, default='=' - How the numeric portion of the label is rendered. - '=' shows the actual p-value with `' = '` inserted after `prefix`. - '<' replaces the p-value with the star-bracket threshold it falls - below, with `' < '` inserted after `prefix`, giving - `'p < 0.0001'`, `'p < 0.001'`, `'p < 0.01'`, or `'p < 0.05'`. - Non-significant comparisons render as `'p > 0.05'`. - None disables symbol insertion; the caller supplies any symbol via - `prefix`. - separator : str, default=' ' - Separator used to join components when `label` is a sequence with - more than one entry (e.g. `('stars', 'padj')`). - threshold : float | None, default=None - If provided, only comparisons whose p-value (adjusted if `correction` is not - 'none') is below this threshold are drawn. + label : {'stars', 'pvalue'}, default='stars' + The bracket label to draw, both from the adjusted p-value. + 'stars' maps p-values to asterisks (`****` <= 0.0001, `***` <= 0.001, + `**` <= 0.01, `*` <= 0.05, `ns` otherwise). + 'pvalue' prints the p-value with 2 significant digits. + hide_ns : bool, default=False + With `label='stars'`, drop the `ns` (not significant) brackets. y_position : float | None, default=None Vertical position of the lowest bracket. If None, it is placed above the maximum `y` value with a gap of `y_padding * y_range`. @@ -358,7 +194,7 @@ def bracket( color : str, default='#1f1f1f' Color of the brackets and labels. label_size : float | None, default=None - Font size of the label text. If None, `geom_bracket`'s default is used. + Font size of the label text. If None, ggrust's default is used. segment_size : float, default=0.5 Line width of the bracket segments. x : str | None, default=None @@ -370,9 +206,8 @@ def bracket( mapping : FeatureSpec | None, default=None Additional aesthetic mappings for the layer, the result of `aes()`. **geom_kwargs - Additional parameters for the `geom_bracket` layer. - For more information on geom_bracket parameters, see: - https://lets-plot.org/python/pages/api/lets_plot.geom_bracket.html + Additional parameters for the `stat_compare_means` layer, e.g. + `tip_length`. Returns ------- @@ -388,13 +223,14 @@ def bracket( If `comparisons` contains malformed pairs or groups not present in the plot data. ValueError - If a statistical option or label option is invalid, or there are too - few observations to compute brackets. + If `test`, `correction` or `label` is invalid, or there are too few + observations to compute brackets. Notes ----- - Pairwise tests are computed from the plot's retrieved DataFrame using the `x` - aesthetic as the grouping column and the `y` aesthetic as the value column. + Pairwise tests are computed by ggrust's `stat_compare_means` from the plot's + retrieved DataFrame, using the `x` aesthetic as the grouping column and the + `y` aesthetic as the value column. Rows the plot dropped are absent from the test too, so a plot built with `threshold` compares only the observations above it. @@ -435,7 +271,7 @@ def bracket( ) box + cl.bracket( comparisons=[("Monocytes", "Erythroid"), ("Monocytes", "B Cells")], - label="padj", + label="pvalue", y_padding=0.2, ) @@ -449,38 +285,15 @@ def bracket( y_padding=0.2, ) - Hide non-significant brackets by setting a threshold on the adjusted p-value. - - .. jupyter-execute:: - - box + cl.bracket(threshold=0.001,y_padding=0.2) - - Adjust the label format places and add a prefix. - - .. jupyter-execute:: - :emphasize-lines: 4-5 - - box + cl.bracket( - threshold=0.05, - label="pvalue", - prefix="p", - prefix_style="=", - y_padding=0.2, - ) - - Use "<" notation instead. + Hide non-significant brackets. .. jupyter-execute:: - :emphasize-lines: 4 - box + cl.bracket( - threshold=0.05, - label="pvalue", - prefix="p", - prefix_style="<", - y_padding=0.2, - ) + box + cl.bracket(hide_ns=True, y_padding=0.2) """ + method = _option("test", test, _TEST_METHODS) + p_adjust_method = _option("correction", correction, _CORRECTION_METHODS) + p_label = _option("label", label, _LABELS) explicit_plot = plot explicit_x = x explicit_y = y @@ -499,21 +312,11 @@ def _build(receiving_plot: PlotSpec) -> LayerSpec: msg = "`y` is present neither as argument nor in the plot aesthetics." raise MissingAestheticError(msg) - # compute the pairwise significance frame - brackets = _compute_bracket_frame( + finite_frame, pairs, positions = _bracket_layout( frame, x=x, y=y, comparisons=comparisons, - test=test, - alternative=alternative, - correction=correction, - label=label, - label_format=label_format, - prefix=prefix, - prefix_style=prefix_style, - separator=separator, - threshold=threshold, y_position=y_position, y_step=y_step, y_padding=y_padding, @@ -523,9 +326,16 @@ def _build(receiving_plot: PlotSpec) -> LayerSpec: local_mapping = mapping or aes() size_kwargs = {} if label_size is None else {"size": label_size} local_color = None if "color" in local_mapping.as_dict() else color - return geom_bracket( - data=brackets, - mapping=aes(xmin="xmin", xmax="xmax", y="y", label="label", **local_mapping.as_dict()), + return stat_compare_means( + aes(x=x, y=y, **local_mapping.as_dict()), + data=finite_frame, + method=method, + comparisons=pairs, + hide_ns=hide_ns, + label=p_label, + label_y=positions, + p_adjust_method=p_adjust_method, + inherit_aes=False, color=local_color, segment_size=segment_size, **size_kwargs, diff --git a/cellestial/single/core/dimensional.py b/cellestial/single/core/dimensional.py index 7c501912..9c4387e7 100644 --- a/cellestial/single/core/dimensional.py +++ b/cellestial/single/core/dimensional.py @@ -143,7 +143,7 @@ def dimensional( axis_type : {'axis', 'arrow'} | None Whether to use regular axis or arrows as the axis. arrow_length : float, default=0.25 - Span of each axis line as a fraction of its data range (0.25 covers 25%). + Span of each axis line as a fraction of the panel (0.25 covers 25%). arrow_size : float, default=1 Width of the axis lines. arrow_color : str, default='#3f3f3f' @@ -339,9 +339,6 @@ def dimensional( # HANDLE: arrow axis scttr += _modify_axis( - frame=frame, - x=x, - y=y, axis_type=axis_type, arrow_size=arrow_size, arrow_color=arrow_color, diff --git a/cellestial/single/heatmap/dotplot.py b/cellestial/single/heatmap/dotplot.py index ba8805cc..36e7cba7 100644 --- a/cellestial/single/heatmap/dotplot.py +++ b/cellestial/single/heatmap/dotplot.py @@ -9,7 +9,6 @@ from ggrust import ( aes, element_line, - geom_path, geom_point, geom_rect, ggplot, @@ -33,9 +32,10 @@ from cellestial.themes import _THEME_DOTPLOT from cellestial.util import ( _color_gradient, + _dendrogram_line_theme, + _dendrogram_y_axis, _fill_gradient, - _get_dendrogram, - _get_dendrogram_path_frame, + _get_dendrogram_tree, _resolve_tooltips, _validate_tooltips, _warn, @@ -75,7 +75,6 @@ def dotplot( dendrogram_color: str = "black", dendrogram_size: float = 0.5, dendrogram_key: str | None = None, - dendrogram_kwargs: dict | None = None, rectangle_size: float = 0.8, rectangle_color: str = "#3f3f3f", rectangle_kwargs: dict | None = None, @@ -153,8 +152,6 @@ def dotplot( dendrogram_key : str | None, default=None Specific key holding the precomputed dendrogram. By default, `dendrogram_{group_by}` is used. - dendrogram_kwargs : dict | None, default=None - Additional parameters to pass to the dendrogram geom_segment. rectangle_size : float, default=0.8 Size (thickness) of the rectangle border. rectangle_color : str, default='#3f3f3f' @@ -359,9 +356,10 @@ def dotplot( # DETERMINE: y order of groups if dendrogram: - y_order_groups, paths = _get_dendrogram( + tree = _get_dendrogram_tree( *_modality_source(data, modality, group_by), use_key=dendrogram_key ) + y_order_groups = list(tree.ordered_labels) else: y_order_groups = ( frame.select(group_by).unique(maintain_order=True)[group_by].cast(pl.String).to_list() @@ -428,22 +426,6 @@ def dotplot( + _size_scale ) - # DENDROGRAM (right side, along y-axis), built first so we can derive tight x limits - x_max_limit = n_x - 0.5 - if dendrogram: - group_centers = [float(i) for i in range(n_y)] - dendrogram_frame = _get_dendrogram_path_frame( - paths, n_x=n_x, n_groups=n_y, group_centers=group_centers - ) - x_max_limit = dendrogram_frame["x"].max() - dtplt += geom_path( - data=dendrogram_frame, - mapping=aes(x="x", y="y", group="group"), - color=dendrogram_color, - size=dendrogram_size, - **(dendrogram_kwargs or {}), - ) - # AXES: discrete labels via continuous breaks; tight limits keep ticks against the rectangle data_top = n_y - 0.5 y_max_limit = data_top @@ -455,7 +437,6 @@ def dotplot( key_groups, padding=None, data_range=data_range, - size_unit="y", label_size_scale=key_labels_text_size, ) key_groups_total_span = data_range + key_groups_padding @@ -463,15 +444,19 @@ def dotplot( dtplt += scale_x_continuous( breaks=list(range(n_x)), labels=x_keys, - limits=[-0.5, x_max_limit], - expand=[0, 0], - ) - dtplt += scale_y_continuous( - breaks=list(range(n_y)), - labels=y_order_groups, - limits=[-0.5, y_max_limit], + limits=[-0.5, n_x - 0.5], expand=[0, 0], ) + # DENDROGRAM (right side, along y-axis): the tree orders and labels the groups + if dendrogram: + dtplt += _dendrogram_y_axis(tree, limits=[-0.5, y_max_limit]) + else: + dtplt += scale_y_continuous( + breaks=list(range(n_y)), + labels=y_order_groups, + limits=[-0.5, y_max_limit], + expand=[0, 0], + ) # KEY-GROUP brackets above the data area when `keys` was a mapping. if key_labels and key_groups is not None: @@ -486,8 +471,6 @@ def dotplot( bracket_size=key_labels_bracket_size, width=key_labels_width, label_size_scale=key_labels_text_size, - label_size_span=n_x, - size_unit="x", ): dtplt += layer @@ -512,6 +495,8 @@ def dotplot( # ADD: layers dtplt += _THEME_DOTPLOT + if dendrogram: + dtplt += _dendrogram_line_theme(color=dendrogram_color, size=dendrogram_size) # HANDLE: interactive if interactive: diff --git a/cellestial/single/heatmap/heatmap.py b/cellestial/single/heatmap/heatmap.py index 89a81476..b1c36354 100644 --- a/cellestial/single/heatmap/heatmap.py +++ b/cellestial/single/heatmap/heatmap.py @@ -39,9 +39,12 @@ ) from cellestial.themes import _THEME_HEATMAP from cellestial.util import ( + _dendrogram_line_theme, + _dendrogram_y_axis, _fill_gradient, _get_dendrogram, _get_dendrogram_path_frame, + _get_dendrogram_tree, _warn, ) from cellestial.util.errors import _unsupported_data_type @@ -77,7 +80,6 @@ def heatmap( dendrogram_size: float = 0.5, dendrogram_key: str | None = None, group_lines_kwargs: dict | None = None, - dendrogram_kwargs: dict | None = None, key_labels: bool = True, key_labels_text_size: float = 1.0, key_labels_bracket_size: float = 0.6, @@ -161,8 +163,6 @@ def heatmap( By default, `dendrogram_{group_by}` is used. group_lines_kwargs : dict | None, default=None Additional parameters to pass to the group separator lines geom_segment. - dendrogram_kwargs : dict | None, default=None - Additional parameters to pass to the dendrogram geom_path. key_labels : bool, default=True Whether to draw bracket labels above the plot when `keys` is a mapping. key_labels_text_size : float, default=1.0 @@ -406,7 +406,13 @@ def heatmap( frame = _scale_wide_values(frame, keys=keys, scale_axis=scale_axis) # DETERMINE: y order of groups - if dendrogram: + if dendrogram and aggregate: + tree = _get_dendrogram_tree( + *_modality_source(data, modality, group_by), use_key=dendrogram_key + ) + y_order_groups = list(tree.ordered_labels) + paths = None + elif dendrogram: y_order_groups, paths = _get_dendrogram( *_modality_source(data, modality, group_by), use_key=dendrogram_key ) @@ -490,12 +496,16 @@ def heatmap( data_range=data_range, scale=1.0, label_size_scale=key_labels_text_size, + square_cell_columns=n_x if aggregate and geom == "raster" else None, ) key_groups_total_span = data_range + key_groups_padding y_max_limit = data_top + key_groups_padding # Y scale: groups for aggregate, group-center labels when titling bars, else hidden - if aggregate: + if aggregate and dendrogram: + # DENDROGRAM (right side, along y-axis): the tree orders and labels the groups + htmp += _dendrogram_y_axis(tree, limits=[data_bottom, y_max_limit], fixed=geom == "raster") + elif aggregate: htmp += scale_y_continuous( breaks=list(range(n_y)), labels=y_order_groups, @@ -542,8 +552,10 @@ def heatmap( **(group_lines_kwargs or {}), ) - # DENDROGRAM (right side, along y-axis) - if dendrogram: + # DENDROGRAM (right side, along y-axis); per-cell rows draw the tree as a path + if dendrogram and aggregate: + htmp += _dendrogram_line_theme(color=dendrogram_color, size=dendrogram_size) + elif dendrogram: assert paths is not None dendro_frame = _get_dendrogram_path_frame( paths, n_x=n_x, n_groups=len(y_order_groups), group_centers=group_centers @@ -553,7 +565,6 @@ def heatmap( mapping=aes(x="x", y="y", group="group"), color=dendrogram_color, size=dendrogram_size, - **(dendrogram_kwargs or {}), ) # FILL gradient @@ -606,7 +617,6 @@ def matrixplot( dendrogram_size: float = 0.5, dendrogram_key: str | None = None, group_lines_kwargs: dict | None = None, - dendrogram_kwargs: dict | None = None, key_labels: bool = True, key_labels_text_size: float = 1.0, key_labels_bracket_size: float = 0.6, @@ -677,8 +687,6 @@ def matrixplot( By default, `dendrogram_{group_by}` is used. group_lines_kwargs : dict | None, default=None Additional parameters to pass to the group separator lines geom_segment. - dendrogram_kwargs : dict | None, default=None - Additional parameters to pass to the dendrogram geom_path. key_labels : bool, default=True Whether to draw bracket labels above the plot when `keys` is a mapping. key_labels_text_size : float, default=1.0 @@ -797,7 +805,6 @@ def matrixplot( dendrogram_size=dendrogram_size, dendrogram_key=dendrogram_key, group_lines_kwargs=group_lines_kwargs, - dendrogram_kwargs=dendrogram_kwargs, value_column=value_column, variable_column=variable_column, color_low=color_low, diff --git a/cellestial/single/heatmap/stacked_violin.py b/cellestial/single/heatmap/stacked_violin.py index e24b3349..d1b38f24 100644 --- a/cellestial/single/heatmap/stacked_violin.py +++ b/cellestial/single/heatmap/stacked_violin.py @@ -9,7 +9,6 @@ from ggrust import ( aes, element_line, - geom_path, geom_polygon, geom_rect, ggplot, @@ -32,10 +31,11 @@ ) from cellestial.themes import _THEME_DOTPLOT from cellestial.util import ( + _dendrogram_line_theme, + _dendrogram_y_axis, _drop_nonfinite_rows, _fill_gradient, - _get_dendrogram, - _get_dendrogram_path_frame, + _get_dendrogram_tree, _resolve_tooltips, _validate_tooltips, ) @@ -76,7 +76,6 @@ def stacked_violin( dendrogram_color: str = "black", dendrogram_size: float = 0.5, dendrogram_key: str | None = None, - dendrogram_kwargs: dict | None = None, rectangle: bool = True, rectangle_size: float = 0.8, rectangle_color: str = "#3f3f3f", @@ -177,8 +176,6 @@ def stacked_violin( dendrogram_key : str | None, default=None Specific key holding the precomputed dendrogram. By default, `dendrogram_{group_by}` is used. - dendrogram_kwargs : dict | None, default=None - Additional parameters to pass to the dendrogram geom_path. rectangle : bool, default=True Whether to add a rectangle border around the data area. rectangle_size : float, default=0.8 @@ -351,14 +348,14 @@ def stacked_violin( # DETERMINE: y order of groups (dendrogram or first-seen order) if dendrogram: - y_order_groups, paths = _get_dendrogram( + tree = _get_dendrogram_tree( *_modality_source(data, modality, group_by), use_key=dendrogram_key ) + y_order_groups = list(tree.ordered_labels) else: y_order_groups = ( frame.select(group_by).unique(maintain_order=True)[group_by].cast(pl.String).to_list() ) - paths = None x_keys = keys_list n_x = len(x_keys) @@ -420,23 +417,6 @@ def stacked_violin( midpoint=midpoint, ) - # DENDROGRAM (right side, along y-axis), built first so we can derive tight x limits - x_max_limit = n_x - 0.5 - if dendrogram: - assert paths is not None - group_centers = [float(i) for i in range(n_y)] - dendrogram_frame = _get_dendrogram_path_frame( - paths, n_x=n_x, n_groups=n_y, group_centers=group_centers - ) - x_max_limit = dendrogram_frame["x"].max() - plot += geom_path( - data=dendrogram_frame, - mapping=aes(x="x", y="y", group="group"), - color=dendrogram_color, - size=dendrogram_size, - **(dendrogram_kwargs or {}), - ) - # AXES: discrete labels via continuous breaks; tight limits keep ticks against the rectangle data_top = n_y - 0.5 y_max_limit = data_top @@ -454,15 +434,19 @@ def stacked_violin( plot += scale_x_continuous( breaks=list(range(n_x)), labels=x_keys, - limits=[-0.5, x_max_limit], - expand=[0, 0], - ) - plot += scale_y_continuous( - breaks=list(range(n_y)), - labels=y_order_groups, - limits=[-0.5, y_max_limit], + limits=[-0.5, n_x - 0.5], expand=[0, 0], ) + # DENDROGRAM (right side, along y-axis): the tree orders and labels the groups + if dendrogram: + plot += _dendrogram_y_axis(tree, limits=[-0.5, y_max_limit]) + else: + plot += scale_y_continuous( + breaks=list(range(n_y)), + labels=y_order_groups, + limits=[-0.5, y_max_limit], + expand=[0, 0], + ) # KEY-GROUP brackets above the data area when `keys` was a mapping. if key_labels and key_groups is not None: @@ -501,6 +485,8 @@ def stacked_violin( # ADD: theme plot += _THEME_DOTPLOT + if dendrogram: + plot += _dendrogram_line_theme(color=dendrogram_color, size=dendrogram_size) # HANDLE: interactive if interactive: diff --git a/cellestial/single/heatmap/utilities.py b/cellestial/single/heatmap/utilities.py index 4f121bbb..e24b2abc 100644 --- a/cellestial/single/heatmap/utilities.py +++ b/cellestial/single/heatmap/utilities.py @@ -293,20 +293,8 @@ def _get_group_lines_frame( _TIP_LENGTH_FRACTION = 0.018 _LABEL_GAP_FRACTION = 0.012 -# Label sizing has two modes: -# -# - "absolute" (default, used by heatmap): label_size is in lets-plot's default -# text-size unit (~pt-like). Works well when no other scale_size interferes -# with the geom_text. Padding is computed as a fraction of the total y-axis -# span using the original heuristic. -# -# - "y" (used by dotplot and stacked_violin): label_size is in y-axis units -# via `geom_text(size_unit="y")`. This bypasses the silent shrinkage that -# `scale_size(range=[...])` applies to `geom_text` size constants in -# plots that map a size aesthetic. Padding is computed directly from the -# rotated text extent in y-axis units. - -# Absolute mode (heatmap). +# Label size is in the default text-size unit (~pt-like). Padding is a +# fraction of the total y-axis span. _ABS_LABEL_PADDING_BASE_FRACTION = 0.060 _ABS_LABEL_PADDING_PER_CHAR_SIZE_FRACTION = 0.0051 _ABS_LABEL_PADDING_MIN_DENSITY_SCALE = 0.45 @@ -317,17 +305,8 @@ def _get_group_lines_frame( _ABS_LABEL_TEXT_SIZE_PER_KEY = 0.06 _ABS_LABEL_TEXT_SIZE_PER_EXTRA_CHAR = 0.08 _ABS_LABEL_TEXT_SIZE_CHAR_THRESHOLD = 16 - -# y-unit mode (dotplot, stacked_violin). -# Empirically calibrated: cap height ~ 22 px on a typical 6-inch panel when -# size = 0.037 * total_span y units. Each rotated character occupies roughly -# `_Y_CHAR_WIDTH_PER_SIZE * label_size` y units along the y axis. -_Y_LABEL_VISUAL_CAP_FRACTION = 0.037 -_Y_LABEL_LONG_THRESHOLD = 12 -_Y_LABEL_LONG_MIN_FRACTION = 0.6 -_Y_LABEL_SIZE_FLOOR = 0.10 -_Y_CHAR_WIDTH_PER_SIZE = 0.40 -_Y_PADDING_SAFETY_FRACTION = 0.030 +# Square cells: padding in column widths per column, label character and size unit. +_SQUARE_CELL_PADDING_PER_CHAR_SIZE = 0.0038 def _resolve_key_groups( @@ -377,7 +356,7 @@ def _resolve_key_groups( def _label_size_absolute(groups: dict[str, list[str]], *, scale: float = 1.0) -> float: - """Label size in absolute lets-plot text units (used without `size_unit`).""" + """Label size in absolute text units.""" n_keys = sum(len(values) for values in groups.values()) max_chars = max((len(label) for label in groups), default=0) extra_chars = max(0, max_chars - _ABS_LABEL_TEXT_SIZE_CHAR_THRESHOLD) @@ -393,23 +372,6 @@ def _label_size_absolute(groups: dict[str, list[str]], *, scale: float = 1.0) -> return max(_ABS_LABEL_TEXT_SIZE_MIN, size * scale) -def _label_size_y_units(groups: dict[str, list[str]], *, span: float, scale: float = 1.0) -> float: - """ - Label size in y-axis units (used with `geom_text(size_unit='y')`). - - Notes - ----- - Scales with `span` so the rendered visual size stays roughly constant - across plots with different y-axis spans. Long labels are shrunk slightly - to keep the bracket area from dominating the plot. - """ - max_chars = max((len(label) for label in groups), default=0) - visual_fraction = _Y_LABEL_VISUAL_CAP_FRACTION - if max_chars > _Y_LABEL_LONG_THRESHOLD: - visual_fraction *= max(_Y_LABEL_LONG_MIN_FRACTION, _Y_LABEL_LONG_THRESHOLD / max_chars) - return max(_Y_LABEL_SIZE_FLOOR, visual_fraction * span * scale) - - def _resolve_padding( groups: dict[str, list[str]], *, @@ -417,7 +379,7 @@ def _resolve_padding( data_range: float | None = None, scale: float = 1.0, label_size_scale: float = 1.0, - size_unit: str | None = None, + square_cell_columns: int | None = None, ) -> float: """ Compute y-axis padding (in data units) for the bracket area. @@ -426,41 +388,36 @@ def _resolve_padding( ----- `padding` overrides the auto value when given. `scale` is a per-plot multiplier applied to the auto padding (use `<1` for a more compact - bracket area). `size_unit` selects the sizing mode (`None` for the - absolute heatmap mode, `"y"` for the y-unit dotplot/stacked_violin mode). + bracket area). `square_cell_columns` is the column count of a plot with + square cells (`geom_raster`): one y unit is then one column wide, so the + labels need more y units as columns are added. """ if padding is not None: return padding data = data_range if data_range is not None and data_range > 0 else 5.0 max_chars = max((len(label) for label in groups), default=0) - - if size_unit is None: - n_keys = sum(len(values) for values in groups.values()) - label_size = _label_size_absolute(groups, scale=label_size_scale) - density_scale = max( - _ABS_LABEL_PADDING_MIN_DENSITY_SCALE, - min(1.0, n_keys / _ABS_LABEL_PADDING_FULL_DENSITY_KEYS), - ) - target_fraction = min( - _BAR_OFFSET_FRACTION - + _LABEL_GAP_FRACTION - + _ABS_LABEL_PADDING_BASE_FRACTION - + _ABS_LABEL_PADDING_PER_CHAR_SIZE_FRACTION * max_chars * label_size * density_scale, - _ABS_LABEL_PADDING_MAX_TARGET_FRACTION, + n_keys = sum(len(values) for values in groups.values()) + label_size = _label_size_absolute(groups, scale=label_size_scale) + density_scale = max( + _ABS_LABEL_PADDING_MIN_DENSITY_SCALE, + min(1.0, n_keys / _ABS_LABEL_PADDING_FULL_DENSITY_KEYS), + ) + target_fraction = min( + _BAR_OFFSET_FRACTION + + _LABEL_GAP_FRACTION + + _ABS_LABEL_PADDING_BASE_FRACTION + + _ABS_LABEL_PADDING_PER_CHAR_SIZE_FRACTION * max_chars * label_size * density_scale, + _ABS_LABEL_PADDING_MAX_TARGET_FRACTION, + ) + resolved = target_fraction * data / (1.0 - target_fraction) * scale + if square_cell_columns is not None: + square_padding = ( + _SQUARE_CELL_PADDING_PER_CHAR_SIZE * square_cell_columns * max_chars * label_size ) - resolved = target_fraction * data / (1.0 - target_fraction) * scale - if data_range is None or data_range <= 0: - return max(1.5, resolved) - return resolved - - # y-unit mode: padding fits the rotated text extent directly in y units. - approx_total_span = data * 1.3 - label_size = _label_size_y_units(groups, span=approx_total_span, scale=label_size_scale) - text_extent = max_chars * _Y_CHAR_WIDTH_PER_SIZE * label_size - bar_offset = _BAR_OFFSET_FRACTION * data - label_gap = _LABEL_GAP_FRACTION * data - safety = _Y_PADDING_SAFETY_FRACTION * data - return (bar_offset + label_gap + text_extent + safety) * scale + resolved = max(resolved, square_padding * scale) + if data_range is None or data_range <= 0: + return max(1.5, resolved) + return resolved def _build_key_groups_frame( @@ -515,8 +472,6 @@ def _key_groups_layers( bracket_size: float = 0.6, width: float = 0.6, label_size_scale: float = 1.0, - label_size_span: float | None = None, - size_unit: str | None = None, extra_kwargs: dict | None = None, ) -> list[FeatureSpec]: """ @@ -527,11 +482,6 @@ def _key_groups_layers( Each bracket is one connected `geom_path` (left tip down, top bar across, right tip down) so the corners join cleanly with no sub-pixel gap. A vertical label is centered on each bracket as a separate `geom_text`. - - `size_unit` selects the label sizing mode (`None` for absolute units, - otherwise express the size in that axis' data units). `label_size_span` - lets callers size text from a different axis span than the y span used for - bracket geometry. """ frame = _build_key_groups_frame(groups, y=y, width=width) tip_length = _TIP_LENGTH_FRACTION * total_span @@ -564,18 +514,8 @@ def _key_groups_layers( "hjust": 0, "vjust": 0.5, } - if size_unit is not None: - text_kwargs["size_unit"] = size_unit text_kwargs.update(extra_kwargs or {}) - - if size_unit is None: - label_size = _label_size_absolute(groups, scale=label_size_scale) - else: - label_size = _label_size_y_units( - groups, - span=total_span if label_size_span is None else label_size_span, - scale=label_size_scale, - ) + label_size = _label_size_absolute(groups, scale=label_size_scale) return [ geom_path( diff --git a/cellestial/util/__init__.py b/cellestial/util/__init__.py index 45e7949b..5840f06e 100644 --- a/cellestial/util/__init__.py +++ b/cellestial/util/__init__.py @@ -1,6 +1,9 @@ from cellestial.util.dendrogram import ( # noqa: F401 + _dendrogram_line_theme, + _dendrogram_y_axis, _get_dendrogram, _get_dendrogram_path_frame, + _get_dendrogram_tree, ) from cellestial.util.markers import marker_genes, marker_genes_dict from cellestial.util.operations import get_figure, get_figures, get_mapping, layout, retrieve diff --git a/cellestial/util/dendrogram.py b/cellestial/util/dendrogram.py index d49aa9b9..6dcba0db 100644 --- a/cellestial/util/dendrogram.py +++ b/cellestial/util/dendrogram.py @@ -1,13 +1,98 @@ from __future__ import annotations -__lazy_modules__ = ["anndata", "numpy"] +__lazy_modules__ = ["anndata", "ggrust", "ggrust.plot.core", "numpy"] import numpy as np import polars as pl from anndata import AnnData +from ggrust import ( + ClusterTree, + coord_cartesian, + coord_fixed, + element_line, + guide_axis_dendro, + scale_y_dendro, + theme, +) +from ggrust.plot.core import FeatureSpec, FeatureSpecArray from cellestial.util.errors import KeyNotFoundError +# geom line widths are drawn at `size * 2.2` px, theme lines at `size` px; +# scaling keeps `dendrogram_size` in the geom units of the former path layer. +_GEOM_LINE_UNIT = 2.2 + + +def _dendrogram_info( + data: AnnData, + group_by: str, + *, + use_key: str | None = None, +) -> dict: + """Get or compute the scanpy dendrogram of `group_by` stored in `data.uns`.""" + if not isinstance(data, AnnData): + msg = f"Unsupported data type: `{type(data)}`" + raise TypeError(msg) + key = use_key if use_key is not None else f"dendrogram_{group_by}" + if key not in data.uns: + if group_by not in data.obs.columns: + # Reached when a multimodal object groups by a container-level + # column that the selected modality does not carry. + msg = ( + f"Cannot compute a dendrogram for `{group_by}`: it is not an " + "observation column of the data the dendrogram is computed from.\n" + "Precompute the dendrogram, or group by a column that data carries." + ) + raise KeyNotFoundError(msg) + import scanpy as sc + + sc.tl.dendrogram(data, groupby=group_by, key_added=key) + return data.uns[key] + + +def _get_dendrogram_tree( + data: AnnData, + group_by: str, + *, + use_key: str | None = None, +) -> ClusterTree: + """ + Get or compute the dendrogram of `group_by` as a ggrust tree. + + Leaves are labelled by category; the tree's order is scanpy's + `categories_ordered`. + """ + dendro = _dendrogram_info(data, group_by, use_key=use_key) + # Linkage leaf `i` is category `i`; recover the categories from the stored order. + labels = [""] * len(dendro["categories_ordered"]) + for index, category in zip( + dendro["categories_idx_ordered"], dendro["categories_ordered"], strict=True + ): + labels[index] = str(category) + return ClusterTree.from_linkage(dendro["linkage"], labels) + + +def _dendrogram_y_axis( + tree: ClusterTree, *, limits: list[float], fixed: bool = False +) -> FeatureSpecArray: + """ + Order the y-axis by `tree` and draw it right of the panel, as scanpy does. + + Layers keep numeric y positions: leaf `i` of the tree's order sits at `i`. + `limits` replace the continuous scale's limits (the tree fixes the levels); + `fixed` keeps the square cells `geom_raster` plots default to. + """ + coord = coord_fixed(ylim=limits) if fixed else coord_cartesian(ylim=limits) + return scale_y_dendro(tree, expand=[0, 0], guide=guide_axis_dendro(side="opposite")) + coord + + +def _dendrogram_line_theme(*, color: str, size: float) -> FeatureSpec: + """Style the y-axis tree, drawn with the y tick line; zero-length ticks stay hidden.""" + return theme( + axis_ticks_y=element_line(color=color, size=size * _GEOM_LINE_UNIT), + axis_ticks_length_y=0, + ) + def _get_dendrogram( data: AnnData, @@ -17,22 +102,7 @@ def _get_dendrogram( ) -> tuple[list[str], pl.DataFrame]: """Get or compute the dendrogram for `group_by` and extract paths.""" if isinstance(data, AnnData): - key = use_key if use_key is not None else f"dendrogram_{group_by}" - if key not in data.uns: - if group_by not in data.obs.columns: - # Reached when a multimodal object groups by a container-level - # column that the selected modality does not carry. - msg = ( - f"Cannot compute a dendrogram for `{group_by}`: it is not an " - "observation column of the data the dendrogram is computed from.\n" - "Precompute the dendrogram, or group by a column that data carries." - ) - raise KeyNotFoundError(msg) - import scanpy as sc - - sc.tl.dendrogram(data, groupby=group_by, key_added=key) - - dendro = data.uns[key] + dendro = _dendrogram_info(data, group_by, use_key=use_key) categories_ordered = list(dendro["categories_ordered"]) dendro_info = dendro["dendrogram_info"] diff --git a/cellestial/util/operations.py b/cellestial/util/operations.py index 03b45996..ba572056 100644 --- a/cellestial/util/operations.py +++ b/cellestial/util/operations.py @@ -13,13 +13,6 @@ from polars import DataFrame -def _grid_figures(grid: SupPlotsSpec) -> list[PlotSpec]: - # Lets-Plot does not expose the underlying PlotSpec list on SupPlotsSpec; - # `.as_dict()["figures"]` returns serialized dicts that `gggrid` cannot consume. - # Name-mangled access is the only path to the live PlotSpec objects. - return vars(grid)["_SupPlotsSpec__figures"] - - def _normalize_widths( plots: Sequence[Sequence[PlotSpec | SupPlotsSpec]], widths: Sequence[float] | Sequence[Sequence[float]] | None, @@ -222,7 +215,7 @@ def get_figure(grid: SupPlotsSpec, index: int) -> PlotSpec: msg = f"Expected int for index, but received {type(index)}" raise TypeError(msg) - figures = _grid_figures(grid) + figures = grid.figures if not -len(figures) <= index < len(figures): msg = f"index {index} is out of range for a grid with {len(figures)} figures." raise IndexError(msg) @@ -286,7 +279,7 @@ def get_figures(grid: SupPlotsSpec, indices: Sequence[int], **kwargs) -> SupPlot msg = f"Expected Sequence[int] for indices, but received {type(indices)}" raise TypeError(msg) - figures = _grid_figures(grid) + figures = grid.figures out_of_range = [i for i in indices if not -len(figures) <= i < len(figures)] if out_of_range: msg = f"indices {out_of_range} are out of range for a grid with {len(figures)} figures" diff --git a/notebooks/completed/pval.ipynb b/notebooks/completed/pval.ipynb index df52c80c..105510ff 100644 --- a/notebooks/completed/pval.ipynb +++ b/notebooks/completed/pval.ipynb @@ -45,7 +45,7 @@ " mapping=aes(x=\"cell_type_lvl1\", y=\"CD3D\", fill=\"cell_type_lvl1\"),\n", " stat=\"summary\",\n", ")\n", - "br + cl.layers.bracket(y_position=1.2, prefix=\"p\", prefix_style=\"<\", label=\"pvalue\")" + "br + cl.layers.bracket(y_position=1.2, label=\"pvalue\")" ] }, { diff --git a/poetry.lock b/poetry.lock index 7260e99f..1698ce0c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.5.1 and should not be changed by hand. [[package]] name = "accessible-pygments" @@ -261,22 +261,22 @@ legacy-api-wrap = "*" natsort = "*" numpy = ">=1.26" packaging = ">=24.2" -pandas = ">=2.1.0,<2.1.2 || >2.1.2,<3" -scipy = ">=1.12,<1.17.0 || >1.17.0" +pandas = ">=2.1.0,!=2.1.2,<3" +scipy = ">=1.12,!=1.17.0" scverse-misc = ">=0.0.3" -zarr = ">=2.18.7,<3.0.dev0 || >=3.1.dev0" +zarr = ">=2.18.7,<3.0 || >=3.1.dev0" [package.extras] cu11 = ["cupy-cuda11x"] cu12 = ["cupy-cuda12x"] -dask = ["dask[array] (>=2023.5.1,<2024.8.dev0 || >=2024.10.dev0,<2025.2.dev0 || >=2025.9.dev0)"] +dask = ["dask[array] (>=2023.5.1,<2024.8 || >=2024.10.dev0,<2025.2 || >=2025.9.dev0)"] dev = ["towncrier (>=24.8.0)"] dev-doc = ["towncrier (>=24.8.0)"] -doc = ["awkward (>=2.6.3)", "dask[array] (>=2023.5.1,<2024.8.dev0 || >=2024.10.dev0,<2025.2.dev0 || >=2025.9.dev0)", "ipython", "myst-nb", "myst-parser", "scanpydoc[theme,typehints] (>=0.17.1)", "sphinx (>=9.1)", "sphinx-autodoc-typehints (>=3.6.2)", "sphinx-book-theme (>=1.1.0)", "sphinx-copybutton", "sphinx-design", "sphinx-issues (>=5.0.1)", "sphinxext-opengraph", "towncrier (>=24.8.0)"] +doc = ["awkward (>=2.6.3)", "dask[array] (>=2023.5.1,<2024.8 || >=2024.10.dev0,<2025.2 || >=2025.9.dev0)", "ipython", "myst-nb", "myst-parser", "scanpydoc[theme,typehints] (>=0.17.1)", "sphinx (>=9.1)", "sphinx-autodoc-typehints (>=3.6.2)", "sphinx-book-theme (>=1.1.0)", "sphinx-copybutton", "sphinx-design", "sphinx-issues (>=5.0.1)", "sphinxext-opengraph", "towncrier (>=24.8.0)"] gpu = ["cupy"] -lazy = ["aiohttp", "dask[array] (>=2023.5.1,<2024.8.dev0 || >=2024.10.dev0,<2025.2.dev0 || >=2025.9.dev0)", "requests", "xarray (>=2025.6.1)"] -test = ["aiohttp", "awkward (>=2.3.2)", "boltons", "dask[array] (>=2023.5.1,<2024.8.dev0 || >=2024.10.dev0,<2025.2.dev0 || >=2025.9.dev0)", "dask[distributed]", "filelock", "joblib", "loompy (>=3.0.5)", "matplotlib", "openpyxl", "pooch", "pyarrow", "pytest", "pytest-cov", "pytest-memray", "pytest-mock", "pytest-randomly", "pytest-xdist[psutil]", "requests", "scanpy (>=1.10)", "scikit-learn", "xarray (>=2025.6.1)"] -test-min = ["awkward (>=2.3.2)", "boltons", "dask[array] (>=2023.5.1,<2024.8.dev0 || >=2024.10.dev0,<2025.2.dev0 || >=2025.9.dev0)", "dask[distributed]", "filelock", "joblib", "loompy (>=3.0.5)", "matplotlib", "openpyxl", "pooch", "pyarrow", "pytest", "pytest-cov", "pytest-memray", "pytest-mock", "pytest-randomly", "pytest-xdist[psutil]", "scanpy (>=1.10)", "scikit-learn"] +lazy = ["aiohttp", "dask[array] (>=2023.5.1,<2024.8 || >=2024.10.dev0,<2025.2 || >=2025.9.dev0)", "requests", "xarray (>=2025.6.1)"] +test = ["aiohttp", "awkward (>=2.3.2)", "boltons", "dask[array] (>=2023.5.1,<2024.8 || >=2024.10.dev0,<2025.2 || >=2025.9.dev0)", "dask[distributed]", "filelock", "joblib", "loompy (>=3.0.5)", "matplotlib", "openpyxl", "pooch", "pyarrow", "pytest", "pytest-cov", "pytest-memray", "pytest-mock", "pytest-randomly", "pytest-xdist[psutil]", "requests", "scanpy (>=1.10)", "scikit-learn", "xarray (>=2025.6.1)"] +test-min = ["awkward (>=2.3.2)", "boltons", "dask[array] (>=2023.5.1,<2024.8 || >=2024.10.dev0,<2025.2 || >=2025.9.dev0)", "dask[distributed]", "filelock", "joblib", "loompy (>=3.0.5)", "matplotlib", "openpyxl", "pooch", "pyarrow", "pytest", "pytest-cov", "pytest-memray", "pytest-mock", "pytest-randomly", "pytest-xdist[psutil]", "scanpy (>=1.10)", "scikit-learn"] [[package]] name = "annsel" @@ -312,7 +312,7 @@ version = "0.1.4" description = "Disable App Nap on macOS >= 10.9" optional = false python-versions = ">=3.6" -groups = ["main", "dev", "docs"] +groups = ["dev", "docs"] markers = "platform_system == \"Darwin\"" files = [ {file = "appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c"}, @@ -391,7 +391,7 @@ version = "3.0.1" description = "Annotate AST trees with source code positions" optional = false python-versions = ">=3.8" -groups = ["main", "dev", "docs"] +groups = ["dev", "docs"] files = [ {file = "asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a"}, {file = "asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7"}, @@ -487,7 +487,7 @@ markers = {main = "extra == \"spatial\""} [package.dependencies] jmespath = ">=0.7.1,<2.0.0" python-dateutil = ">=2.1,<3.0.0" -urllib3 = ">=1.25.4,<2.2.0 || >2.2.0,<3" +urllib3 = ">=1.25.4,!=2.2.0,<3" [package.extras] crt = ["awscrt (==0.32.2)"] @@ -554,7 +554,7 @@ version = "2.0.0" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.9" -groups = ["main", "dev", "docs"] +groups = ["dev", "docs"] files = [ {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, @@ -641,7 +641,7 @@ files = [ {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, ] -markers = {main = "implementation_name == \"pypy\"", docs = "implementation_name == \"pypy\""} +markers = {docs = "implementation_name == \"pypy\""} [package.dependencies] pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} @@ -822,11 +822,11 @@ description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["main", "dev", "docs"] -markers = "sys_platform == \"win32\" or platform_system == \"Windows\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {main = "platform_system == \"Windows\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\"", docs = "platform_system == \"Windows\" or sys_platform == \"win32\""} [[package]] name = "colorcet" @@ -855,7 +855,7 @@ version = "0.2.3" description = "Jupyter Python Comm implementation, for usage in ipykernel, xeus-python etc." optional = false python-versions = ">=3.8" -groups = ["main", "dev", "docs"] +groups = ["dev", "docs"] files = [ {file = "comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417"}, {file = "comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971"}, @@ -1203,7 +1203,7 @@ version = "1.8.20" description = "An implementation of the Debug Adapter Protocol for Python" optional = false python-versions = ">=3.8" -groups = ["main", "dev", "docs"] +groups = ["dev", "docs"] files = [ {file = "debugpy-1.8.20-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:157e96ffb7f80b3ad36d808646198c90acb46fdcfd8bb1999838f0b6f2b59c64"}, {file = "debugpy-1.8.20-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:c1178ae571aff42e61801a38b007af504ec8e05fde1c5c12e5a7efef21009642"}, @@ -1243,7 +1243,7 @@ version = "5.3.1" description = "Decorators for Humans" optional = false python-versions = ">=3.8" -groups = ["main", "dev", "docs"] +groups = ["dev", "docs"] files = [ {file = "decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c"}, {file = "decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82"}, @@ -1304,7 +1304,7 @@ packaging = ">=20.0" psutil = ">=5.8.0" pyyaml = ">=5.4.1" sortedcontainers = ">=2.0.5" -tblib = ">=1.6.0,<3.2.0 || >3.2.0,<3.2.1 || >3.2.1" +tblib = ">=1.6.0,!=3.2.0,!=3.2.1" toolz = ">=0.12.0" tornado = ">=6.2.0" urllib3 = ">=1.26.5" @@ -1376,7 +1376,7 @@ version = "2.2.1" description = "Get the currently executing AST node of a frame, and other information" optional = false python-versions = ">=3.8" -groups = ["main", "dev", "docs"] +groups = ["dev", "docs"] files = [ {file = "executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017"}, {file = "executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4"}, @@ -1680,7 +1680,7 @@ files = [ markers = {main = "extra == \"spatial\""} [package.dependencies] -aiohttp = {version = "<4.0.0a0 || >4.0.0a0,<4.0.0a1 || >4.0.0a1", optional = true, markers = "extra == \"http\""} +aiohttp = {version = "!=4.0.0a0,!=4.0.0a1", optional = true, markers = "extra == \"http\""} s3fs = {version = ">2024.2.0", optional = true, markers = "extra == \"s3\""} [package.extras] @@ -1757,23 +1757,22 @@ dev = ["codecov", "pre-commit", "pytest (>=3.1.0)", "pytest-cov", "pytest-xdist" [[package]] name = "ggrust" -version = "0.3.0" +version = "0.6.0" description = "Grammar-of-graphics plotting engine in Rust with a lets_plot-compatible Python API" optional = false python-versions = ">=3.11" groups = ["main"] files = [ - {file = "ggrust-0.3.0-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c034d5a539e1bc60de8e5dbc0f360341fd9514c6ec36cccb209883913fa99bc2"}, - {file = "ggrust-0.3.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:f2bb40834bb5929f3431bf0ea0e765706f70beac390aa5084e0c5fbb3e841354"}, - {file = "ggrust-0.3.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:81775c0079ec576604fc2de772fd29d80994cc34be08c8ae449d8476f3159a6a"}, - {file = "ggrust-0.3.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b18f0bfa70480d3dc631b3f0eff6b1c8248f71a13f9261659448771abb7eaa7a"}, - {file = "ggrust-0.3.0-cp311-abi3-win_amd64.whl", hash = "sha256:62d9415bb3f2dc88aa795dd1cdc38ecbf80f66831c90bee2d47da36deeec88c5"}, - {file = "ggrust-0.3.0-cp311-abi3-win_arm64.whl", hash = "sha256:55db7f3454373d067611b6e361a45dbaefbf1de41816418f13fef29d1826baa7"}, - {file = "ggrust-0.3.0.tar.gz", hash = "sha256:3e0e8a2b4f4a1466ee7f4151225690b89b6aedff52143dc76513c782f4231f88"}, + {file = "ggrust-0.6.0-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:14d127b2d054e006456ab734b95e457212809b118a7c89c66bccec0d7bcb3990"}, + {file = "ggrust-0.6.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:51f34622c8483f7b329c965107444f5bb949b4fabb015ce6c658d2c6e785a66b"}, + {file = "ggrust-0.6.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:b5625149e90c35a4a068b07ebddebabb728f6dd9f804d35bcae1521805e33c8d"}, + {file = "ggrust-0.6.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c17cf3fd994dd2db708e0a08e8782e1e129668271587cd95baf8902c7502749a"}, + {file = "ggrust-0.6.0-cp311-abi3-win_amd64.whl", hash = "sha256:97a9ca7fb18defb9117a5311cbaba6422dd8eeb95c544bc09c83142bd45198df"}, + {file = "ggrust-0.6.0-cp311-abi3-win_arm64.whl", hash = "sha256:eda08c8a01d015e2c278653ba3f010e56ddb4944365e6a431e882c7037f1e7d0"}, + {file = "ggrust-0.6.0.tar.gz", hash = "sha256:1386f9101cf3c265d15dc50e019890fe872272adc4fae0316337622923827ce1"}, ] [package.dependencies] -ipykernel = ">=7.4.0" polars = ">=1.30" [[package]] @@ -2180,7 +2179,7 @@ version = "7.4.0" description = "IPython Kernel for Jupyter" optional = false python-versions = ">=3.11" -groups = ["main", "dev", "docs"] +groups = ["dev", "docs"] files = [ {file = "ipykernel-7.4.0-py3-none-any.whl", hash = "sha256:a6757f790ddc5a6006b813d82da1a9dbb65b3a00f2b6607df776418c6566ec1c"}, {file = "ipykernel-7.4.0.tar.gz", hash = "sha256:4330114d22b9b33575b2c7c68753fc8faabbbcfbc60b0cdff9f14dd4ce63742f"}, @@ -2192,7 +2191,7 @@ comm = ">=0.1.1" debugpy = ">=1.6.5" ipython = ">=7.23.1" jupyter-client = ">=8.9.0" -jupyter-core = ">=5.1,<6.0.dev0 || >=6.1.dev0" +jupyter-core = ">=5.1,<6.0 || >=6.1.dev0" matplotlib-inline = ">=0.1" nest-asyncio2 = ">=1.7.0" packaging = ">=22" @@ -2206,7 +2205,7 @@ version = "9.13.0" description = "IPython: Productive Interactive Computing" optional = false python-versions = ">=3.11" -groups = ["main", "dev", "docs"] +groups = ["dev", "docs"] files = [ {file = "ipython-9.13.0-py3-none-any.whl", hash = "sha256:57f9d4639e20818d328d287c7b549af3d05f12486ea8f2e7f73e52a36ec4d201"}, {file = "ipython-9.13.0.tar.gz", hash = "sha256:7e834b6afc99f020e3f05966ced34792f40267d64cb1ea9043886dab0dde5967"}, @@ -2240,7 +2239,7 @@ version = "1.1.1" description = "Defines a variety of Pygments lexers for highlighting IPython code." optional = false python-versions = ">=3.8" -groups = ["main", "dev", "docs"] +groups = ["dev", "docs"] files = [ {file = "ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c"}, {file = "ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81"}, @@ -2277,7 +2276,7 @@ version = "0.20.0" description = "An autocompletion tool for Python that can be used for text editors." optional = false python-versions = ">=3.10" -groups = ["main", "dev", "docs"] +groups = ["dev", "docs"] files = [ {file = "jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67"}, {file = "jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011"}, @@ -2378,7 +2377,7 @@ version = "8.10.0" description = "Jupyter protocol implementation and client libraries" optional = false python-versions = ">=3.10" -groups = ["main", "dev", "docs"] +groups = ["dev", "docs"] files = [ {file = "jupyter_client-8.10.0-py3-none-any.whl", hash = "sha256:5f73f24f22fa25192cfff6b23c051932a2473a797b05734aff495b392103e14e"}, {file = "jupyter_client-8.10.0.tar.gz", hash = "sha256:9f7116294dca55f1785be880057d44544db9b1567718d92cb33c58886afb9497"}, @@ -2403,7 +2402,7 @@ version = "5.9.1" description = "Jupyter core package. A base package on which Jupyter projects rely." optional = false python-versions = ">=3.10" -groups = ["main", "dev", "docs"] +groups = ["dev", "docs"] files = [ {file = "jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407"}, {file = "jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508"}, @@ -3039,7 +3038,7 @@ version = "0.2.2" description = "Inline Matplotlib backend for Jupyter" optional = false python-versions = ">=3.9" -groups = ["main", "dev", "docs"] +groups = ["dev", "docs"] files = [ {file = "matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6"}, {file = "matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79"}, @@ -3543,7 +3542,7 @@ files = [ [package.dependencies] jupyter-client = ">=6.1.12" -jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" +jupyter-core = ">=4.12,<5.0 || >=5.1.dev0" nbformat = ">=5.1.3" traitlets = ">=5.4" @@ -3604,7 +3603,7 @@ files = [ [package.dependencies] fastjsonschema = ">=2.15" jsonschema = ">=2.6" -jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" +jupyter-core = ">=4.12,<5.0 || >=5.1.dev0" traitlets = ">=5.1" [package.extras] @@ -3632,7 +3631,7 @@ version = "1.7.4" description = "Patch asyncio to allow nested event loops" optional = false python-versions = ">=3.5" -groups = ["main", "dev", "docs"] +groups = ["dev", "docs"] files = [ {file = "nest_asyncio2-1.7.4-py3-none-any.whl", hash = "sha256:86e77b52c3d1496b294af3c944b44213b9b82a12a5ee590c0acc4ca4692ef6c7"}, {file = "nest_asyncio2-1.7.4.tar.gz", hash = "sha256:995b1409d926ec55e0876ea39a69bffdd8852cf1db3652c177e8c8ffdbcaf3cc"}, @@ -3890,7 +3889,7 @@ markers = {main = "extra == \"spatial\""} aiohttp = "*" dask = ">=2025.12.0,<=2026.1.1" Deprecated = "*" -fsspec = {version = ">=0.8,<2021.7.0 || >2021.7.0,<2023.9.0 || >2023.9.0", extras = ["s3"]} +fsspec = {version = ">=0.8,!=2021.7.0,!=2023.9.0", extras = ["s3"]} numpy = "*" rangehttpserver = "*" requests = "*" @@ -4078,7 +4077,7 @@ version = "0.8.7" description = "A Python Parser" optional = false python-versions = ">=3.6" -groups = ["main", "dev", "docs"] +groups = ["dev", "docs"] files = [ {file = "parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c"}, {file = "parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1"}, @@ -4163,7 +4162,7 @@ version = "4.9.0" description = "Pexpect allows easy control of interactive console applications." optional = false python-versions = "*" -groups = ["main", "dev", "docs"] +groups = ["dev", "docs"] markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"}, @@ -4312,6 +4311,7 @@ files = [ {file = "platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a"}, {file = "platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7"}, ] +markers = {main = "extra == \"spatial\""} [[package]] name = "playwright" @@ -4445,7 +4445,7 @@ version = "3.0.52" description = "Library for building powerful interactive command lines in Python" optional = false python-versions = ">=3.8" -groups = ["main", "dev", "docs"] +groups = ["dev", "docs"] files = [ {file = "prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955"}, {file = "prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855"}, @@ -4616,6 +4616,7 @@ files = [ {file = "psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee"}, {file = "psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372"}, ] +markers = {main = "extra == \"spatial\""} [package.extras] dev = ["abi3audit", "black", "check-manifest", "colorama ; os_name == \"nt\"", "coverage", "packaging", "psleak", "pylint", "pyperf", "pypinfo", "pyreadline3 ; os_name == \"nt\"", "pytest", "pytest-cov", "pytest-instafail", "pytest-xdist", "pywin32 ; os_name == \"nt\" and implementation_name != \"pypy\"", "requests", "rstcheck", "ruff", "setuptools", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "validate-pyproject[all]", "virtualenv", "vulture", "wheel", "wheel ; os_name == \"nt\" and implementation_name != \"pypy\"", "wmi ; os_name == \"nt\" and implementation_name != \"pypy\""] @@ -4627,7 +4628,7 @@ version = "0.7.0" description = "Run a subprocess in a pseudo terminal" optional = false python-versions = "*" -groups = ["main", "dev", "docs"] +groups = ["dev", "docs"] markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, @@ -4640,7 +4641,7 @@ version = "0.2.3" description = "Safely evaluate AST nodes without side effects" optional = false python-versions = "*" -groups = ["main", "dev", "docs"] +groups = ["dev", "docs"] files = [ {file = "pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0"}, {file = "pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42"}, @@ -4743,12 +4744,12 @@ version = "3.0" description = "C parser in Python" optional = false python-versions = ">=3.10" -groups = ["main", "dev", "docs"] +groups = ["dev", "docs"] files = [ {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, ] -markers = {main = "implementation_name == \"pypy\"", dev = "implementation_name != \"PyPy\"", docs = "implementation_name == \"pypy\""} +markers = {dev = "implementation_name != \"PyPy\"", docs = "implementation_name == \"pypy\""} [[package]] name = "pyct" @@ -4827,6 +4828,7 @@ files = [ {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, ] +markers = {main = "extra == \"spatial\""} [package.extras] windows-terminal = ["colorama (>=0.4.6)"] @@ -5238,7 +5240,7 @@ version = "27.1.0" description = "Python bindings for 0MQ" optional = false python-versions = ">=3.8" -groups = ["main", "dev", "docs"] +groups = ["dev", "docs"] files = [ {file = "pyzmq-27.1.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:508e23ec9bc44c0005c4946ea013d9317ae00ac67778bd47519fdf5a0e930ff4"}, {file = "pyzmq-27.1.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:507b6f430bdcf0ee48c0d30e734ea89ce5567fd7b8a0f0044a369c176aa44556"}, @@ -5605,7 +5607,7 @@ markers = {main = "extra == \"spatial\""} [package.dependencies] aiobotocore = ">=2.19.0,<4.0.0" -aiohttp = ">=3.9.0,<4.0.0a0 || >4.0.0a0,<4.0.0a1 || >4.0.0a1" +aiohttp = ">=3.9.0,!=4.0.0a0,!=4.0.0a1" fsspec = "2026.4.0" [[package]] @@ -5631,7 +5633,7 @@ leidenalg = {version = ">=0.9.0", optional = true, markers = "extra == \"leiden\ matplotlib = ">=3.7.5" natsort = "*" networkx = ">=2.7.1" -numba = ">=0.57.1,<0.62.0rc1 || >0.62.0rc1" +numba = ">=0.57.1,!=0.62.0rc1" numpy = ">=1.24.1" packaging = ">=21.3" pandas = ">=1.5.3" @@ -5779,7 +5781,7 @@ files = [ ] [package.dependencies] -imageio = ">=2.33,<2.35.0 || >2.35.0" +imageio = ">=2.33,!=2.35.0" lazy-loader = ">=0.4" networkx = ">=3.0" numpy = ">=1.24" @@ -5959,7 +5961,7 @@ loompy = ">=2.0.12" matplotlib = ">=3.3.0" numba = ">=0.41.0" numpy = ">=1.17" -pandas = ">=1.1.1,<1.4.0 || >1.4.0" +pandas = ">=1.1.1,!=1.4.0" scanpy = ">=1.5" scikit-learn = ">=0.21.2" scipy = ">=1.4.1" @@ -6024,8 +6026,8 @@ files = [ markers = {main = "extra == \"extras\""} [package.dependencies] -matplotlib = ">=3.4,<3.6.1 || >3.6.1" -numpy = ">=1.20,<1.24.0 || >1.24.0" +matplotlib = ">=3.4,!=3.6.1" +numpy = ">=1.20,!=1.24.0" pandas = ">=1.2" [package.extras] @@ -6577,7 +6579,7 @@ version = "0.6.3" description = "Extract data from python stack frames and tracebacks for informative displays" optional = false python-versions = "*" -groups = ["main", "dev", "docs"] +groups = ["dev", "docs"] files = [ {file = "stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695"}, {file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"}, @@ -6641,9 +6643,9 @@ markers = {main = "extra == \"extras\""} [package.dependencies] numpy = ">=1.22.3,<3" packaging = ">=21.3" -pandas = ">=1.4,<2.1.0 || >2.1.0" +pandas = ">=1.4,!=2.1.0" patsy = ">=0.5.6" -scipy = ">=1.8,<1.9.2 || >1.9.2" +scipy = ">=1.8,!=1.9.2" [package.extras] build = ["cython (>=3.0.10)"] @@ -6833,6 +6835,7 @@ files = [ {file = "tornado-6.6-cp315-cp315t-win_arm64.whl", hash = "sha256:7de62e2dfdf559fd7154d2b9aa58ff3d9cff8304ae0519341feb082618bc2a33"}, {file = "tornado-6.6.tar.gz", hash = "sha256:34c4aa916d7f2b2d5cadee2c6850f5a4a7f3d688cbf58399ea9bd3057af4abd5"}, ] +markers = {main = "extra == \"spatial\""} [[package]] name = "tqdm" @@ -6862,7 +6865,7 @@ version = "5.15.0" description = "Traitlets Python configuration system" optional = false python-versions = ">=3.9" -groups = ["main", "dev", "docs"] +groups = ["dev", "docs"] files = [ {file = "traitlets-5.15.0-py3-none-any.whl", hash = "sha256:fb36a18867a6803deab09f3c5e0fa81bb7b26a5c9e82501c9933f759166eff40"}, {file = "traitlets-5.15.0.tar.gz", hash = "sha256:4fead733f81cf1c4c938e06f8ca4633896833c9d89eff878159457f4d4392971"}, @@ -7028,7 +7031,7 @@ version = "0.7.0" description = "Measures the displayed width of unicode strings in a terminal" optional = false python-versions = ">=3.8" -groups = ["main", "dev", "docs"] +groups = ["dev", "docs"] files = [ {file = "wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2"}, {file = "wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0"}, @@ -7596,4 +7599,4 @@ spatial = ["spatialdata"] [metadata] lock-version = "2.1" python-versions = ">=3.11,<4.0" -content-hash = "70f85b8a93e24873feee1f17d0cb946edaeea9800557ad88f33c039905929d88" +content-hash = "3498d51739cf1dc0db505960833e2cdece95f1e7090231c28efdd4275742c500" diff --git a/pyproject.toml b/pyproject.toml index 7873183f..0fe6f724 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ classifiers = [ ] dependencies = [ "anndata>=0.10.8,<1.0", - "ggrust>=0.3.0,<0.4.0", + "ggrust>=0.6.0,<0.7.0", "numpy>=1.26,<3", "pandas>=2.2.3,<3", "polars[pyarrow]>=1.30.0,<2.0.0", diff --git a/tests/test_annotated_heatmap.py b/tests/test_annotated_heatmap.py index e4f49a73..39db9a9b 100644 --- a/tests/test_annotated_heatmap.py +++ b/tests/test_annotated_heatmap.py @@ -1,7 +1,7 @@ import polars as pl import pytest from ggrust import geom_blank -from ggrust.plot.subplots import SupPlotsSpec +from ggrust.plot.core import PlotSpec import cellestial as cl from cellestial.complex.annotated_heatmap import _bin_observations @@ -10,7 +10,7 @@ def test_annotated_heatmap_default(adata, markers, group_key): plot = cl.annotated_heatmap(adata, keys=markers, group_by=group_key) - assert isinstance(plot, SupPlotsSpec) + assert isinstance(plot, PlotSpec) def test_annotated_heatmap_both_axes(adata, markers, group_key, cluster_key): @@ -22,14 +22,14 @@ def test_annotated_heatmap_both_axes(adata, markers, group_key, cluster_key): row_annotations=[cluster_key, "pct_counts_mt"], scale_axis=0, ) - assert isinstance(plot, SupPlotsSpec) + assert isinstance(plot, PlotSpec) def test_annotated_heatmap_numeric_column_annotation(adata, markers, group_key): plot = cl.annotated_heatmap( adata, keys=markers, group_by=group_key, column_annotations=["means"] ) - assert isinstance(plot, SupPlotsSpec) + assert isinstance(plot, PlotSpec) def test_annotated_heatmap_string_annotations(adata, markers, group_key, cluster_key): @@ -40,26 +40,26 @@ def test_annotated_heatmap_string_annotations(adata, markers, group_key, cluster column_annotations="highly_variable", row_annotations=cluster_key, ) - assert isinstance(plot, SupPlotsSpec) + assert isinstance(plot, PlotSpec) def test_annotated_heatmap_no_legend(adata, markers, group_key, cluster_key): plot = cl.annotated_heatmap( adata, keys=markers, group_by=group_key, row_annotations=[cluster_key], legend=False ) - assert isinstance(plot, SupPlotsSpec) + assert isinstance(plot, PlotSpec) def test_annotated_heatmap_no_group_by(adata, markers): plot = cl.annotated_heatmap(adata, keys=markers, row_annotations="pct_counts_mt") - assert isinstance(plot, SupPlotsSpec) + assert isinstance(plot, PlotSpec) def test_annotated_heatmap_max_rows(adata, markers, group_key, cluster_key): plot = cl.annotated_heatmap( adata, keys=markers, group_by=group_key, row_annotations=[cluster_key], max_rows=200 ) - assert isinstance(plot, SupPlotsSpec) + assert isinstance(plot, PlotSpec) def test_annotated_heatmap_annotation_colors(adata, markers, group_key): @@ -74,7 +74,7 @@ def test_annotated_heatmap_annotation_colors(adata, markers, group_key): "pct_counts_mt": ["#ffffcc", "#800026"], }, ) - assert isinstance(plot, SupPlotsSpec) + assert isinstance(plot, PlotSpec) def test_annotated_heatmap_dendrogram(adata, markers, group_key): @@ -85,7 +85,7 @@ def test_annotated_heatmap_dendrogram(adata, markers, group_key): row_annotations=[group_key], dendrogram=True, ) - assert isinstance(plot, SupPlotsSpec) + assert isinstance(plot, PlotSpec) def test_annotated_heatmap_dendrogram_requires_group_by(adata, markers): @@ -102,7 +102,7 @@ def test_annotated_heatmap_passthrough_params(adata, markers, group_key): interactive=True, alpha=0.9, ) - assert isinstance(plot, SupPlotsSpec) + assert isinstance(plot, PlotSpec) def test_annotated_heatmap_unsupported_data_type(markers, group_key): @@ -131,6 +131,63 @@ def test_bin_observations_noop_when_small(): assert _bin_observations(frame, group_by=None, max_rows=1000) is frame +def _side_layers(spec: dict, band: str) -> list[dict]: + return [layer for layer in spec["layers"] if layer.get("ggside") == band] + + +def test_annotated_heatmap_is_one_plot_with_side_tracks(adata, markers, group_key, cluster_key): + spec = cl.annotated_heatmap( + adata, + keys=markers[:2], + group_by=group_key, + column_annotations=["highly_variable"], + row_annotations=[cluster_key, "pct_counts_mt"], + max_rows=20, + ).as_dict() + + assert spec["mapping"] == {"x": "position_x", "y": "position_y"} + assert spec["layers"][0]["mapping"] == {"fill": "value"} + assert spec["ggside"] == {"x_pos": "top", "y_pos": "left"} + assert spec["theme"]["ggside_panel_scale_x"] == pytest.approx(0.025) + assert spec["theme"]["ggside_panel_scale_y"] == pytest.approx(0.05) + assert [layer["mapping"]["fill"] for layer in _side_layers(spec, "x")] == ["highly_variable"] + assert [layer["mapping"]["fill"] for layer in _side_layers(spec, "y")] == [ + cluster_key, + "pct_counts_mt", + ] + # every track has its own fill scale group, so its own legend + groups = [layer["scale_group"]["fill"] for layer in spec["layers"] if "ggside" in layer] + assert len(set(groups)) == 3 + side_scales = {scale["ggside"]: scale for scale in spec["scales"] if "ggside" in scale} + # first listed track outermost: leftmost in the left band + assert side_scales["y"]["limits"] == [cluster_key, "pct_counts_mt"] + # track titles hidden by default: the legends name the tracks, and the band + # axes stay on the main axes' sides (no stray line on the far side) + assert side_scales["x"]["breaks"] == [] + assert side_scales["y"]["breaks"] == [] + assert "position" not in side_scales["x"] + assert "position" not in side_scales["y"] + + +def test_annotated_heatmap_track_titles_shown(adata, markers, group_key, cluster_key): + spec = cl.annotated_heatmap( + adata, + keys=markers[:2], + group_by=group_key, + column_annotations=["highly_variable"], + row_annotations=[cluster_key], + track_titles=True, + max_rows=20, + ).as_dict() + side_scales = {scale["ggside"]: scale for scale in spec["scales"] if "ggside" in scale} + + assert "breaks" not in side_scales["x"] + assert "breaks" not in side_scales["y"] + assert side_scales["x"]["position"] == "right" + # turned names on top, so they don't inflate the bottom axis area + assert side_scales["y"]["position"] == "top" + + def test_annotated_heatmap_transpose_moves_tracks_and_group_lines( adata, markers, group_key, cluster_key ): @@ -144,16 +201,11 @@ def test_annotated_heatmap_transpose_moves_tracks_and_group_lines( max_rows=20, ).as_dict() - assert spec["layout"]["widths"] == [0.025, 1.0] - assert spec["layout"]["heights"] == [0.025, 1.0] - assert spec["figures"][0] is None - heatmap = spec["figures"][3] - assert heatmap["layers"][0]["mapping"] == { - "x": "position_y", - "y": "position_x", - "fill": "value", - } - assert heatmap["layers"][1]["mapping"] == {"x": "x", "xend": "x"} + assert spec["mapping"] == {"x": "position_y", "y": "position_x"} + assert spec["ggside"] == {"x_pos": "top", "y_pos": "left"} + assert [layer["mapping"]["fill"] for layer in _side_layers(spec, "x")] == [cluster_key] + assert [layer["mapping"]["fill"] for layer in _side_layers(spec, "y")] == ["highly_variable"] + assert spec["layers"][1]["mapping"] == {"x": "x", "xend": "x"} def test_annotated_heatmap_right_and_bottom_track_positions( @@ -164,20 +216,16 @@ def test_annotated_heatmap_right_and_bottom_track_positions( keys=markers[:2], group_by=group_key, column_annotations=["highly_variable"], - row_annotations=[cluster_key], + row_annotations=[cluster_key, "pct_counts_mt"], row_annotation_position="right", column_annotation_position="bottom", max_rows=20, ).as_dict() - assert spec["layout"]["widths"] == [1.0, 0.025] - assert spec["layout"]["heights"] == [1.0, 0.025] - assert spec["figures"][3] is None - assert spec["figures"][0]["layers"][0]["mapping"] == { - "x": "position_x", - "y": "position_y", - "fill": "value", - } + assert spec["ggside"] == {"x_pos": "bottom", "y_pos": "right"} + side_scales = {scale["ggside"]: scale for scale in spec["scales"] if "ggside" in scale} + # first listed track outermost: rightmost in the right band + assert side_scales["y"]["limits"] == ["pct_counts_mt", cluster_key] def test_annotated_heatmap_transpose_moves_dendrogram_above_heatmap(adata, markers, group_key): @@ -190,29 +238,29 @@ def test_annotated_heatmap_transpose_moves_dendrogram_above_heatmap(adata, marke max_rows=20, ).as_dict() - assert spec["layout"]["ncol"] == 1 - assert spec["layout"]["nrow"] == 2 - assert spec["figures"][0]["layers"][0]["geom"] == "path" - assert spec["figures"][1]["layers"][0]["geom"] == "raster" + assert spec["layers"][0]["geom"] == "raster" + (tree,) = _side_layers(spec, "x") + assert tree["geom"] == "line" + assert spec["ggside"]["x_pos"] == "top" + # root away from the heatmap: heights grow upward from the band's inner edge + assert min(tree["data"]["height"]) == pytest.approx(-0.5) + assert max(tree["data"]["height"]) > 0 -def test_annotated_heatmap_layers_and_layers_all_have_distinct_scope( - adata, markers, group_key, cluster_key -): +def test_annotated_heatmap_layers_and_layers_all_are_added(adata, markers, group_key, cluster_key): spec = cl.annotated_heatmap( adata, keys=markers[:2], group_by=group_key, - column_annotations=["highly_variable"], row_annotations=[cluster_key], layers=geom_blank(), layers_all=geom_blank(), max_rows=20, ).as_dict() - panels = [panel for panel in spec["figures"] if panel is not None] - blank_counts = [sum(layer["geom"] == "blank" for layer in panel["layers"]) for panel in panels] + geoms = [layer["geom"] for layer in spec["layers"]] - assert sorted(blank_counts) == [1, 1, 2] + # both come right after the heatmap, before the tracks' new fill scales + assert geoms[:3] == ["raster", "blank", "blank"] def test_annotated_heatmap_raster_warns_and_removes_tooltips(adata, markers, group_key): @@ -226,11 +274,5 @@ def test_annotated_heatmap_raster_warns_and_removes_tooltips(adata, markers, gro max_rows=20, ) - heatmap_layer = next( - layer - for panel in plot.as_dict()["figures"] - if panel is not None - for layer in panel["layers"] - if layer["geom"] == "raster" - ) + heatmap_layer = next(layer for layer in plot.as_dict()["layers"] if layer["geom"] == "raster") assert "tooltips" not in heatmap_layer diff --git a/tests/test_audit_regressions.py b/tests/test_audit_regressions.py index 3b4bac15..fe3365aa 100644 --- a/tests/test_audit_regressions.py +++ b/tests/test_audit_regressions.py @@ -10,12 +10,12 @@ import polars as pl import pytest from anndata import AnnData -from ggrust import aes +from ggrust import aes, geom_point, ggplot from scipy.stats import ttest_ind import cellestial as cl from cellestial.datasets import datasets -from cellestial.layers.bracket import _compute_bracket_frame +from cellestial.layers.bracket import _bracket_layout from cellestial.util import retrieve from cellestial.util.utilities import _color_gradient, _fill_gradient, _range_inclusive @@ -43,22 +43,20 @@ def _axis_probe_data() -> AnnData: return data -def _compute_single_bracket(frame: pl.DataFrame) -> pl.DataFrame: - """Compute one uncorrected two-sided Welch t-test bracket.""" - return _compute_bracket_frame( +def _bracket_label(frame: pl.DataFrame) -> str: + """Render one uncorrected two-sided Welch `a` vs `b` bracket and return its label.""" + plot = ggplot(frame) + geom_point(aes(x="group", y="value")) + plot += cl.bracket(comparisons=[("a", "b")], test="ttest", correction="none", label="pvalue") + return re.findall(r"]*>([^<]*)", plot.to_svg())[0] + + +def _single_layout(frame: pl.DataFrame) -> tuple[pl.DataFrame, list, list[float]]: + """Lay out one `a` vs `b` bracket.""" + return _bracket_layout( frame, x="group", y="value", comparisons=[("a", "b")], - test="ttest", - alternative="two-sided", - correction="none", - label="stars", - label_format=".3g", - prefix="", - prefix_style="=", - separator=" ", - threshold=None, y_position=None, y_step=None, y_padding=0.1, @@ -77,13 +75,11 @@ def test_bracket_omits_nonfinite_observations(nonfinite_value): ) expected = ttest_ind([1.0, 2.0], [10.0, 11.0, 12.0], equal_var=False) - brackets = _compute_single_bracket(frame) + finite_frame, _, positions = _single_layout(frame) - assert brackets["statistic"][0] == pytest.approx(expected.statistic) - assert brackets["pvalue"][0] == pytest.approx(expected.pvalue) - assert brackets["pvalue_adj"][0] == pytest.approx(expected.pvalue) - assert np.isfinite(brackets["pvalue"][0]) - assert np.isfinite(brackets["y"][0]) + assert finite_frame.height == 5 + assert float(_bracket_label(frame)) == float(f"{expected.pvalue:.2g}") + assert np.isfinite(positions[0]) @pytest.mark.parametrize("nonfinite_value", [None, np.nan, np.inf, -np.inf]) @@ -98,7 +94,7 @@ def test_bracket_rejects_groups_with_too_few_finite_observations(nonfinite_value ) with pytest.raises(ValueError, match="at least 2 observations"): - _compute_single_bracket(frame) + _single_layout(frame) def test_bracket_rejects_partial_results_with_an_invalid_comparison(): @@ -111,20 +107,11 @@ def test_bracket_rejects_partial_results_with_an_invalid_comparison(): ) with pytest.raises(ValueError, match=r"'a'.*'c'.*\(1\)"): - _compute_bracket_frame( + _bracket_layout( frame, x="group", y="value", comparisons=[("a", "b"), ("a", "c")], - test="ttest", - alternative="two-sided", - correction="none", - label="stars", - label_format=".3g", - prefix="", - prefix_style="=", - separator=" ", - threshold=None, y_position=None, y_step=None, y_padding=0.1, @@ -428,29 +415,6 @@ def _unequal_variance_frame() -> pl.DataFrame: ) -def _bracket_pvalue(frame: pl.DataFrame, test: str) -> float: - """Run one uncorrected pairwise bracket and return its p-value.""" - brackets = _compute_bracket_frame( - frame, - x="group", - y="value", - comparisons=[("a", "b")], - test=test, - alternative="two-sided", - correction="none", - label="stars", - label_format=".3g", - prefix="", - prefix_style="=", - separator=" ", - threshold=None, - y_position=None, - y_step=None, - y_padding=0.1, - ) - return brackets["pvalue"][0] - - def test_bracket_ttest_does_not_assume_equal_variances(): """`ttest` must be Welch's, matching R's `t.test` and scanpy's own ranking.""" frame = _unequal_variance_frame() @@ -458,16 +422,10 @@ def test_bracket_ttest_does_not_assume_equal_variances(): values_b = frame.filter(pl.col("group") == "b")["value"].to_numpy() welch = ttest_ind(values_a, values_b, equal_var=False) - assert _bracket_pvalue(frame, "ttest") == pytest.approx(welch.pvalue) + assert float(_bracket_label(frame)) == float(f"{welch.pvalue:.2g}") -def test_bracket_student_keeps_the_pooled_variance_test(): - """The pooled-variance test stays reachable under its own name.""" - frame = _unequal_variance_frame() - values_a = frame.filter(pl.col("group") == "a")["value"].to_numpy() - values_b = frame.filter(pl.col("group") == "b")["value"].to_numpy() - student = ttest_ind(values_a, values_b, equal_var=True) - - assert _bracket_pvalue(frame, "student") == pytest.approx(student.pvalue) - # the assumption matters: the two tests disagree on this data - assert _bracket_pvalue(frame, "ttest") != pytest.approx(student.pvalue) +def test_bracket_rejects_the_pooled_variance_test(): + """With no pooled-variance t-test in ggrust, `student` is no longer accepted.""" + with pytest.raises(ValueError, match="`test`"): + cl.bracket(test="student") diff --git a/tests/test_bracket_stat.py b/tests/test_bracket_stat.py new file mode 100644 index 00000000..30e84ba7 --- /dev/null +++ b/tests/test_bracket_stat.py @@ -0,0 +1,102 @@ +""" +`bracket` statistics, computed by ggrust's `stat_compare_means`, against scipy. + +The bracket labels carry the p-value at 2 significant digits, so the comparison +is made at that precision. +""" + +import re + +import numpy as np +import polars as pl +import pytest +from ggrust import aes, geom_boxplot, ggplot +from scipy.stats import false_discovery_control, mannwhitneyu, ttest_ind + +import cellestial as cl + +PAIRS = [("A", "B"), ("A", "C"), ("B", "C")] +STARS = ((0.0001, "****"), (0.001, "***"), (0.01, "**"), (0.05, "*")) + + +def _frame(n: int, shifts: tuple[float, float, float], seed: int = 0, *, ties: bool = False): + rng = np.random.default_rng(seed) + values = [rng.normal(shift, 1, n) for shift in shifts] + if ties: + values = [np.round(group) for group in values] + return pl.DataFrame({"g": [g for g in "ABC" for _ in range(n)], "v": np.concatenate(values)}) + + +def _labels(frame: pl.DataFrame, **kwargs) -> list[str]: + plot = ggplot(frame, aes("g", "v")) + geom_boxplot() + plot += cl.bracket(comparisons=PAIRS, **kwargs) + texts = re.findall(r"]*>([^<]*)", plot.to_svg()) + return texts[: len(PAIRS)] + + +def _reference(frame: pl.DataFrame, test: str, correction: str, **test_kwargs) -> list[float]: + groups = {g: frame.filter(pl.col("g") == g)["v"].to_numpy() for g in "ABC"} + run = { + "mannwhitney": lambda a, b: mannwhitneyu(a, b, **test_kwargs), + "ttest": lambda a, b: ttest_ind(a, b, equal_var=False), + }[test] + pvalues = np.array([run(groups[a], groups[b]).pvalue for a, b in PAIRS]) + if correction == "bonferroni": + return np.minimum(pvalues * len(pvalues), 1.0).tolist() + if correction == "fdr_bh": + return false_discovery_control(pvalues).tolist() + return pvalues.tolist() + + +def _two_digits(values) -> list[float]: + return [float(f"{value:.2g}") for value in values] + + +def _stars(pvalue: float) -> str: + return next((stars for threshold, stars in STARS if pvalue <= threshold), "ns") + + +@pytest.mark.parametrize("ties", [False, True]) +@pytest.mark.parametrize("correction", ["none", "bonferroni", "fdr_bh"]) +@pytest.mark.parametrize("test", ["mannwhitney", "ttest"]) +def test_pvalues_and_adjustment_match_scipy(test, correction, ties): + """With >= 50 values per group, the asymptotic scipy tests agree.""" + frame = _frame(60, (0.0, 0.4, 0.8), ties=ties) + labels = _labels(frame, test=test, correction=correction, label="pvalue") + assert [float(label) for label in labels] == _two_digits(_reference(frame, test, correction)) + + +@pytest.mark.parametrize("correction", ["none", "bonferroni", "fdr_bh"]) +@pytest.mark.parametrize("shift", [0.1, 0.3, 0.5, 0.7, 0.9, 1.2]) +def test_stars_follow_the_adjusted_pvalue(shift, correction): + """Stars come from the adjusted p-value with `<=` cutoffs.""" + frame = _frame(60, (0.0, shift, 2 * shift), seed=1) + expected = [_stars(p) for p in _reference(frame, "mannwhitney", correction)] + assert _labels(frame, correction=correction) == expected + + +def test_star_bands_are_all_covered(): + """The star test above reaches every band from 'ns' to '****'.""" + seen = set() + for shift in [0.1, 0.3, 0.5, 0.7, 0.9, 1.2]: + frame = _frame(60, (0.0, shift, 2 * shift), seed=1) + seen |= {_stars(p) for p in _reference(frame, "mannwhitney", "none")} + assert seen == {"ns", "*", "**", "***", "****"} + + +def test_hide_ns_drops_only_ns_brackets(): + """`hide_ns` removes exactly the brackets labelled `ns`.""" + frame = _frame(60, (0.0, 0.1, 0.2), seed=1) + stars = _labels(frame, correction="none") + hidden = _labels(frame, correction="none", hide_ns=True) + assert [star for star in stars if star != "ns"] == [ + label for label in hidden if label in {"*", "**", "***", "****"} + ] + + +def test_small_groups_wilcoxon_is_exact(): + """Below 50 tie-free values per group the Wilcoxon test is exact, as in R.""" + frame = _frame(30, (0.0, 0.5, 1.0)) + labels = _labels(frame, correction="none", label="pvalue") + exact = _reference(frame, "mannwhitney", "none", method="exact") + assert [float(label) for label in labels] == _two_digits(exact) diff --git a/tests/test_browser_plots.py b/tests/test_browser_plots.py index a2278830..0d7ec8f7 100644 --- a/tests/test_browser_plots.py +++ b/tests/test_browser_plots.py @@ -62,7 +62,7 @@ def _histogram(data: AnnData, _markers: list[str], group_key: str) -> PlotSpec: return cl.histogram(data, "n_genes_by_counts", fill=group_key, bins=20) -def _annotated_heatmap(data: AnnData, markers: list[str], group_key: str) -> SupPlotsSpec: +def _annotated_heatmap(data: AnnData, markers: list[str], group_key: str) -> PlotSpec: return cl.annotated_heatmap( data, keys=markers[:3], @@ -86,7 +86,7 @@ def _umaps(data: AnnData, _markers: list[str], group_key: str) -> SupPlotsSpec: pytest.param(_ridge, PlotSpec, id="ridge"), pytest.param(_histogram, PlotSpec, id="histogram"), pytest.param(_heatmap, PlotSpec, id="heatmap"), - pytest.param(_annotated_heatmap, SupPlotsSpec, id="annotated-heatmap"), + pytest.param(_annotated_heatmap, PlotSpec, id="annotated-heatmap"), pytest.param(_umaps, SupPlotsSpec, id="umaps"), ], ) diff --git a/tests/test_coverage_boost.py b/tests/test_coverage_boost.py index 832fec27..98fc4395 100644 --- a/tests/test_coverage_boost.py +++ b/tests/test_coverage_boost.py @@ -11,7 +11,6 @@ from ggrust.plot.subplots import SupPlotsSpec import cellestial as cl -from cellestial.layers import DeferredLayer from cellestial.util.errors import VariableNotFoundError # =========================================================================== @@ -319,9 +318,7 @@ def test_dimensional_axis_type_none(adata): def test_arrow_axis_default(adata): umap = cl.umap(adata, "CD14") - arrow = cl.arrow_axis() - assert isinstance(arrow, DeferredLayer) - combined = umap + arrow + combined = umap + cl.arrow_axis() assert isinstance(combined, PlotSpec) diff --git a/tests/test_heatmap.py b/tests/test_heatmap.py index 5be478d7..3e95251e 100644 --- a/tests/test_heatmap.py +++ b/tests/test_heatmap.py @@ -35,6 +35,23 @@ def _stage_custom_dendrogram(adata, group_key, custom_key="dendrogram_custom_v2" return local, custom_key +def _dendrogram_leaf_order(plot): + """Leaf labels of the plot's dendrogram scale, in the tree's order.""" + (tree,) = (scale["dendrogram"] for scale in plot.as_dict()["scales"] if "dendrogram" in scale) + return [tree["labels"][leaf] for leaf in tree["order"]] + + +@pytest.mark.parametrize("function", ["dotplot", "stacked_violin", "matrixplot"]) +def test_dendrogram_leaf_order_matches_scanpy(adata, markers, group_key, function): + import scanpy as sc + + local = adata.copy() + sc.tl.dendrogram(local, groupby=group_key) + plot = getattr(cl, function)(local, keys=markers, group_by=group_key, dendrogram=True) + expected = list(local.uns[f"dendrogram_{group_key}"]["categories_ordered"]) + assert _dendrogram_leaf_order(plot) == expected + + def test_heatmap_dendrogram_key_uses_custom_key(adata, markers, group_key): local, custom = _stage_custom_dendrogram(adata, group_key) plot = cl.heatmap( @@ -310,29 +327,6 @@ def test_resolve_padding_explicit_overrides_auto(): assert explicit == 0.5 -def test_key_groups_x_unit_label_size_ignores_y_span(): - from cellestial.single.heatmap.utilities import _key_groups_layers - - groups = {"Group_1": ["A", "B"], "Group_2": ["C", "D"]} - small_y_text = _key_groups_layers( - groups, - y=3.5, - total_span=4, - label_size_span=4, - size_unit="x", - )[-1].as_dict() - large_y_text = _key_groups_layers( - groups, - y=15.5, - total_span=16, - label_size_span=4, - size_unit="x", - )[-1].as_dict() - assert small_y_text["size_unit"] == "x" - assert large_y_text["size_unit"] == "x" - assert small_y_text["size"] == large_y_text["size"] - - def test_dotplot_dict_keys_extend_y_limit_for_brackets(adata, group_key): groups = _marker_groups() plot = cl.dotplot(adata, group_by=group_key, keys=groups) @@ -354,10 +348,22 @@ def test_dotplot_dict_keys_without_key_labels_keeps_tight_y_limit(adata, group_k assert scales[-1]["limits"][1] == n_y - 0.5 -def test_dotplot_key_labels_use_x_unit_text_size(adata, group_key): +def test_dotplot_key_labels_use_absolute_text_size(adata, group_key): plot = cl.dotplot(adata, group_by=group_key, keys=_marker_groups()) text_layers = [layer for layer in plot.as_dict()["layers"] if layer.get("geom") == "text"] - assert text_layers[-1]["size_unit"] == "x" + assert "size_unit" not in text_layers[-1] + assert text_layers[-1]["size"] >= 3.75 + + +def test_resolve_padding_square_cells_grow_with_columns(): + from cellestial.single.heatmap.utilities import _resolve_padding + + groups = {f"group number {index}": [str(index)] for index in range(12)} + free = _resolve_padding(groups, padding=None, data_range=4) + six = _resolve_padding(groups, padding=None, data_range=4, square_cell_columns=6) + twelve = _resolve_padding(groups, padding=None, data_range=4, square_cell_columns=12) + assert six >= free, "square cells never get less padding" + assert twelve > six, "one y unit is one column wide, so more columns need more y units" def test_stacked_violin_key_labels_use_absolute_text_size(adata, group_key): diff --git a/tests/test_layers.py b/tests/test_layers.py index d6721aee..76be441c 100644 --- a/tests/test_layers.py +++ b/tests/test_layers.py @@ -8,75 +8,34 @@ from ggrust.plot.core import PlotSpec import cellestial as cl -from cellestial.layers import DeferredLayer -from cellestial.layers.arrow import _axis_arrow_layers -from cellestial.layers.bracket import _compute_bracket_frame, _correct_pvalues, _expand_comparisons +from cellestial.layers.bracket import _bracket_layout, _expand_comparisons from cellestial.layers.ondata_legend import _compute_label_positions from cellestial.util import retrieve from cellestial.util.errors import InvalidComparisonError, MissingAestheticError -def test_arrow_axis_returns_deferred(adata): +def test_arrow_axis_adds_to_plot(adata): umap = cl.umap(adata, "CD14") - arrow = cl.arrow_axis() - assert isinstance(arrow, DeferredLayer) - # Addition should succeed - combined = umap + arrow + combined = umap + cl.arrow_axis() assert isinstance(combined, PlotSpec) def test_arrow_axis_customization(adata): umap = cl.umap(adata, "CD14") - arrow = cl.arrow_axis(length=0.20, color="dark_violet") - assert isinstance(arrow, DeferredLayer) - combined = umap + arrow - assert isinstance(combined, PlotSpec) - - -def test_arrow_axis_explicit_plot_kwarg(adata): - # Explicit `plot=` pins the data source regardless of the receiving plot. - umap = cl.umap(adata, "CD14") - arrow = cl.arrow_axis(plot=umap, length=0.20) - assert isinstance(arrow, DeferredLayer) - # Even when added to an empty ggplot, the data source stays `umap`. - combined = ggplot() + arrow - assert isinstance(combined, PlotSpec) + combined = umap + cl.arrow_axis(length=0.20, color="dark_violet") + theme = combined.as_dict()["theme"] + for key in ("axis_line_x", "axis_line_y"): + assert theme[key]["extent"] == 0.20 + assert theme[key]["color"] == "dark_violet" -def test_arrow_axis_forwards_arrow_and_geom_kwargs(adata): +def test_arrow_axis_forwards_linetype_and_arrow_kwargs(adata): umap = cl.umap(adata, "CD14") combined = umap + cl.arrow_axis(arrow_kwargs={"type": "closed"}, linetype="dashed") - layers = [ - layer for layer in combined.as_dict()["layers"] if layer["geom"] in ("path", "segment") - ] - assert [layer["geom"] for layer in layers] == ["path", "segment", "segment"] - assert all(layer["linetype"] == "dashed" for layer in layers) - assert all(layer["arrow"]["type"] == "closed" for layer in layers[1:]) - - -def test_arrow_axis_rejects_empty_coordinates(): - """Empty coordinates should raise a clear validation error.""" - frame = pl.DataFrame(schema={"x": pl.Float64, "y": pl.Float64}) - - with pytest.raises(ValueError, match="coordinate"): - _axis_arrow_layers( - frame, - x="x", - y="y", - length=0.25, - size=1.0, - color="black", - angle=10.0, - ) - - -def test_arrow_axis_requires_each_coordinate_aesthetic(): - frame = pl.DataFrame({"x": [0.0, 1.0], "y": [0.0, 1.0]}) - - with pytest.raises(MissingAestheticError, match="`x`"): - _ = (ggplot(frame) + geom_point(aes(y="y"))) + cl.arrow_axis() - with pytest.raises(MissingAestheticError, match="`y`"): - _ = (ggplot(frame) + geom_point(aes(x="x"))) + cl.arrow_axis() + theme = combined.as_dict()["theme"] + for key in ("axis_line_x", "axis_line_y"): + assert theme[key]["linetype"] == "dashed" + assert theme[key]["arrow"]["type"] == "closed" def test_ondata_legend_uses_group_median_positions(): @@ -248,26 +207,17 @@ def test_bracket_preserves_group_order_for_default_comparisons(): } ) - brackets = _compute_bracket_frame( + _, pairs, _ = _bracket_layout( frame, x="group", y="value", comparisons=None, - test="mannwhitney", - alternative="two-sided", - correction="none", - label="stars", - label_format=".3g", - prefix="", - prefix_style="=", - separator=" ", - threshold=None, y_position=None, y_step=None, y_padding=0.08, ) - assert list(zip(brackets["xmin"], brackets["xmax"], strict=True)) == [ + assert pairs == [ ("b", "a"), ("b", "c"), ("a", "c"), @@ -329,124 +279,50 @@ def test_bracket_frame_accepts_group_vs_rest_comparisons(): } ) - brackets = _compute_bracket_frame( + _, pairs, _ = _bracket_layout( frame, x="group", y="value", comparisons=[("a", "*")], - test="mannwhitney", - alternative="two-sided", - correction="none", - label="stars", - label_format=".3g", - prefix="", - prefix_style="=", - separator=" ", - threshold=None, y_position=None, y_step=None, y_padding=0.08, ) - assert list(zip(brackets["xmin"], brackets["xmax"], strict=True)) == [ + assert pairs == [ ("a", "b"), ("a", "c"), ] -def test_correct_pvalues_methods_and_invalid_method(): - pvalues = np.array([0.01, 0.02, 0.2]) - - assert _correct_pvalues(pvalues, "none").tolist() == pvalues.tolist() - assert _correct_pvalues(pvalues, "bonferroni").round(3).tolist() == [0.03, 0.06, 0.6] - assert _correct_pvalues(pvalues, "fdr_bh").round(3).tolist() == [0.03, 0.03, 0.2] - with pytest.raises(ValueError, match="correction"): - _correct_pvalues(pvalues, "bad") - - -def test_bracket_frame_ttest_pvalue_labels_and_threshold_empty(): +def test_bracket_layout_explicit_positions(): frame = pl.DataFrame( { - "group": ["a", "a", "a", "b", "b", "b"], - "value": [1.0, 1.1, 1.2, 5.0, 5.1, 5.2], + "group": ["a", "a", "b", "b", "c", "c"], + "value": [1.0, 1.1, 5.0, 5.1, 3.0, 3.1], } ) - brackets = _compute_bracket_frame( + _, _, positions = _bracket_layout( frame, x="group", y="value", - comparisons=[("a", "b")], - test="ttest", - alternative="less", - correction="bonferroni", - label=["stars", "pvalue", "padj"], - label_format=".2g", - prefix="p", - prefix_style="<", - separator="\n", - threshold=None, + comparisons=None, y_position=10.0, y_step=2.0, y_padding=0.1, ) - filtered = _compute_bracket_frame( - frame, - x="group", - y="value", - comparisons=[("a", "b")], - test="ttest", - alternative="less", - correction="bonferroni", - label="pvalue", - label_format=".2g", - prefix="p", - prefix_style=None, - separator=" ", - threshold=1e-12, - y_position=None, - y_step=None, - y_padding=0.1, - ) - assert brackets["y"].to_list() == [10.0] - assert "p <" in brackets["label"][0] - assert filtered.is_empty() + assert positions == [10.0, 12.0, 14.0] -def test_bracket_frame_validation_paths(): - frame = pl.DataFrame( - { - "group": ["a", "a", "b", "b"], - "value": [1.0, 1.1, 2.0, 2.1], - } - ) - kwargs = { - "frame": frame, - "x": "group", - "y": "value", - "comparisons": [("a", "b")], - "test": "mannwhitney", - "alternative": "two-sided", - "correction": "none", - "label_format": ".3g", - "prefix": "", - "prefix_style": "=", - "separator": " ", - "threshold": None, - "y_position": None, - "y_step": None, - "y_padding": 0.1, - } - - with pytest.raises(ValueError, match="test"): - _compute_bracket_frame(**(kwargs | {"test": "bad", "label": "stars"})) - with pytest.raises(ValueError, match="label"): - _compute_bracket_frame(**(kwargs | {"label": ["bad"]})) - with pytest.raises(ValueError, match="at least one"): - _compute_bracket_frame(**(kwargs | {"label": []})) - with pytest.raises(ValueError, match="prefix_style"): - _compute_bracket_frame(**(kwargs | {"label": "pvalue", "prefix_style": "bad"})) +@pytest.mark.parametrize( + "kwargs", + [{"test": "student"}, {"correction": "holm"}, {"label": "padj"}], +) +def test_bracket_rejects_unknown_options(kwargs): + with pytest.raises(ValueError, match=f"`{next(iter(kwargs))}`"): + cl.bracket(**kwargs) def test_bracket_layer_explicit_plot_and_mapping(): @@ -465,9 +341,8 @@ def test_bracket_layer_explicit_plot_and_mapping(): y="value", correction="none", label="pvalue", - mapping=aes(color="label"), + mapping=aes(color="bracket_color"), label_size=6, - threshold=1, ) assert isinstance(combined, PlotSpec) diff --git a/tests/test_mudata.py b/tests/test_mudata.py index 5d577ae8..4077d16e 100644 --- a/tests/test_mudata.py +++ b/tests/test_mudata.py @@ -357,6 +357,10 @@ def test_anndata_frame_is_unchanged(mudata): "stacked_violin", lambda data: cl.stacked_violin(data, keys=[GENE, PROTEIN], group_by=JOINT_GROUP), ), + ( + "annotated_heatmap", + lambda data: cl.annotated_heatmap(data, keys=[GENE, PROTEIN], group_by=JOINT_GROUP), + ), ], ) def test_single_plots_accept_a_container(mudata, name, call): @@ -380,10 +384,6 @@ def test_single_plots_accept_a_container(mudata, name, call): lambda data: cl.expressions(data, keys=[GENE, PROTEIN], group_by=JOINT_GROUP), ), ("xyplots", lambda data: cl.xyplots(data, x=GENE, y=PROTEIN, colors=["celltype"])), - ( - "annotated_heatmap", - lambda data: cl.annotated_heatmap(data, keys=[GENE, PROTEIN], group_by=JOINT_GROUP), - ), ], ) def test_grid_plots_accept_a_container(mudata, name, call):