Source code for vampire.anno.pl._violin

from __future__ import annotations
from typing import TYPE_CHECKING
from typing import Any, Literal, Sequence
import numpy as np
import polars as pl

if TYPE_CHECKING:
    import anndata as ad
    import numpy as np
    import polars as pl
    import plotly.graph_objects as go
    import plotly.subplots as sp

import logging
logger = logging.getLogger(__name__)

from . import _sizing
from ._setting import _save_figure
from ._setting import _get_categorical_colormap
from ._setting import _COLORMAP_OPTIONS # dict[str, list[str] | dict[str, str]]


[docs] def copy_number_violin( adata: "ad.AnnData", *, group_by: str | None = None, group_order: Sequence[str] | None = None, motif: str | int | None = None, log: bool = False, show_box: bool = True, show_points: bool = False, show_counts: bool = True, colormap: dict[str, str] | list[str] | str | None = None, figsize: tuple[int | None, int | None] = (None, None), save: str | bool | None = None, **kwargs, ) -> "go.Figure": """ Plot copy-number distribution across sample groups as a violin plot. Parameters ---------- adata : ad.AnnData Annotated data with copy-number information. group_by : str | none, default is None Column name in ``adata.obs`` used to group samples. If it is None, plot without grouping group_order : Sequence[str] | None, default=None Explicit order for the groups on the x-axis. If ``None``, groups are sorted alphabetically. motif : str | int | None, default=None If ``None``, the total copy number per sample (``adata.obs["copy_number"]``) is used. If ``str``, the motif is looked up in ``adata.var.index`` first, then in ``adata.var["motif"]``, and the matching column from ``adata.X`` is used. If ``int``, ``adata.X[:, motif]`` is used directly. log : bool, default=False Whether to apply ``log1p`` transform to copy-number values before plotting. show_box : bool, default=True Whether to overlay a mini box plot inside each violin. show_points : bool, default=False Whether to overlay individual data points on each violin. show_counts : bool, default=True If ``True``, the x-axis tick label of each group shows the sample count on a second line (``<group><br>n={count}``). colormap : dict[str, str] | list[str] | str | None, default=None Colormap for the violins. If a ``str``, it is looked up in the module and plotly default colormap options. If a list of color strings, used directly. If a dict, keys are group names and values are color strings. If ``None``, the module default rainbow colormap is used. figsize : tuple[int | None, int | None], default=(None, None) Figure size in pixels. save : str | bool | None, default=None If ``True`` or a ``str``, save the figure. A string is appended to the default filename. Infer the filetype if ending on {``'.pdf'``, ``'.png'``, ``'.svg'``}. **kwargs Additional arguments passed to ``fig.update_layout()``. Returns ------- go.Figure Plotly figure with the violin plot. Examples -------- >>> import vampire as vp >>> vp.anno.pl.set_default_plotstyle() >>> adata = vp.datasets.wdr7_hprc() >>> vp.anno.pl.copy_number_violin(adata, group_by="haplotype") >>> vp.anno.pl.copy_number_violin(adata, group_by="ancestry", motif=0) """ import plotly.graph_objects as go import numpy as np if group_by is not None and group_by not in adata.obs.columns: raise KeyError(f"group_by column '{group_by}' not found in adata.obs") # Resolve copy-number vector if motif is None: if "copy_number" not in adata.obs.columns: raise KeyError( "adata.obs['copy_number'] not found. " "Ensure the AnnData object has total copy-number per sample." ) y = adata.obs["copy_number"].to_numpy(dtype=float) y_title = "Copy number" elif isinstance(motif, int): if motif < 0 or motif >= adata.n_vars: raise IndexError( f"motif index {motif} out of range for {adata.n_vars} motifs" ) X = adata.X if hasattr(X, "toarray"): X = X.toarray() y = np.ravel(np.asarray(X, dtype=float)[:, motif]) y_title = "Motif copy number" elif isinstance(motif, str): if motif in adata.var.index: idx = adata.var.index.get_loc(motif) elif "motif" in adata.var.columns and motif in adata.var["motif"].values: idx = adata.var["motif"].tolist().index(motif) else: raise KeyError( f"motif '{motif}' not found in adata.var.index or adata.var['motif']" ) X = adata.X if hasattr(X, "toarray"): X = X.toarray() y = np.ravel(np.asarray(X, dtype=float)[:, idx]) y_title = "Motif copy number" else: raise TypeError(f"motif must be str, int, or None, got {type(motif).__name__}") if group_by is None: groups = ["All"] group_labels = np.array(["All"] * adata.n_obs) xaxis_title = "All" else: group_labels = adata.obs[group_by].astype(str).to_numpy() all_groups = set(adata.obs[group_by].dropna().unique()) if group_order is not None: groups = list(group_order) missing = set(all_groups) - set(groups) if missing: raise ValueError( f"group_order contains unknown groups: {missing}" ) else: groups = sorted(adata.obs[group_by].dropna().unique(), key=str) if len(groups) == 0: raise ValueError(f"No valid groups found in adata.obs['{group_by}']") xaxis_title = group_by # get real font size: user override > active template > fallback font_size = kwargs.get("font", {}).get("size") if font_size is None: font_size = _sizing.get_active_font_size() n_groups: int = len(groups) width, height = _sizing.resolve_figsize( figsize[0], figsize[1], calc_width=lambda: _sizing.violin_width(n_groups, font_size), calc_height=lambda: _sizing.violin_height(font_size), ) fig = go.Figure() group_counts = {} _, colormap = _get_categorical_colormap(groups, colormap if colormap is not None else _COLORMAP_OPTIONS["rainbow"]) for i, group in enumerate(groups): if group_by is None: mask = np.ones(adata.n_obs, dtype=bool) else: mask = (adata.obs[group_by] == group).to_numpy() group_counts[group] = int(mask.sum()) group_y = y[mask] x_vals = [str(group)] * len(group_y) trace_kwargs = dict( x=x_vals, y=group_y, name=str(group), fillcolor=colormap[group], line=dict(color=colormap[group], width=1.2), opacity=0.8, width=0.6, spanmode="hard", side="both", box_visible=show_box, box=dict(fillcolor="white", line=dict(color="black", width=1.2)), meanline_visible=True, hovertemplate="%{x}<br>Count: %{y:.1f}<extra></extra>", ) if show_points: trace_kwargs["points"] = "all" trace_kwargs["jitter"] = 0.2 trace_kwargs["pointpos"] = 0 trace_kwargs["marker"] = dict( color="white", line=dict(color="black", width=1), size=8, opacity=0.9, ) else: trace_kwargs["points"] = False fig.add_trace(go.Violin(**trace_kwargs)) layout = dict( xaxis_title=xaxis_title, yaxis_title=y_title, width=width, height=height, violinmode="group", legend=dict(orientation="h", yanchor="top", y=-0.3, xanchor="center", x=0.5, bgcolor="rgba(0,0,0,0)", borderwidth=0, traceorder="normal", ), margin=dict(l=120, b=130), ) fig.update_layout(**layout) fig.update_layout(**kwargs) fig.update_xaxes(showline=True, linecolor="black", ticks="outside") fig.update_yaxes(showline=True, linecolor="black", ticks="outside") if show_counts: fig.update_xaxes( ticktext=[f"{g}<br>(n={group_counts[g]})" for g in groups], tickvals=groups, ) if log: fig.update_yaxes(type="log") if save: _save_figure(fig, save, "copy_number_violin") return fig
[docs] def copy_number_stacked_violin( adata: "ad.AnnData", *, group_by: str | None = None, group_order: Sequence[str] | None = None, motifs: str | Sequence[str] | None = None, log: bool = False, show_box: bool = False, show_points: bool = False, show_counts: bool = True, colormap: str | Sequence[str] | None = None, row_height: int = 50, figsize: tuple[int | None, int | None] = (None, None), save: str | bool | None = None, **kwargs, ) -> "go.Figure": """ Plot copy-number distributions for multiple motifs as stacked violins. Each row corresponds to one motif; each column corresponds to a group defined by ``group_by``. Useful for comparing copy-number variation across motifs and sample groups simultaneously. Parameters ---------- adata : ad.AnnData Annotated data with motif copy-number matrix in ``X``. group_by : str | None Column name in ``adata.obs`` used to group samples. If it is None, plot without grouping group_order : Sequence[str] | None, default=None Explicit order for the groups on the x-axis. If ``None``, groups are sorted alphabetically. motifs : str | Sequence[str] | None, default=None Motif(s) to visualise. If ``None``, all motifs in ``adata`` are used. If more than 30 motifs are selected a warning is emitted. A single ``str`` or a list/sequence of motif IDs / sequences is accepted. log : bool, default=False Whether to apply ``log1p`` transform to copy-number values before plotting. show_box : bool, default=True Whether to overlay a mini box plot inside each violin. show_points : bool, default=False Whether to overlay individual data points on each violin. show_counts : bool, default=False If ``True``, the x-axis tick label of each group shows the sample count on a second line (``<group><br>n={count}``). colormap : str | Sequence[str] | None, default=None Colormap for the median-based violin fill. If a ``str``, it is passed to ``plotly.colors.sample_colorscale`` (e.g. ``"Viridis"``, ``"Plasma"``). If a sequence of hex/rgb strings, used directly. If ``None``, the module default sequential colormap is used. row_height : int, default=80 Height in pixels allocated to each motif row. figsize : tuple[int | None, int | None] | None, default=(None, None) Figure size in pixels. save : str | bool | None, default=None If ``True`` or a ``str``, save the figure. A string is appended to the default filename. Infer the filetype if ending on {``'.pdf'``, ``'.png'``, ``'.svg'``}. **kwargs Additional arguments passed to ``fig.update_layout()``. Returns ------- go.Figure Plotly figure with stacked violin plots. Examples -------- >>> import vampire as vp >>> vp.anno.pl.set_default_plotstyle() >>> adata = vp.datasets.wdr7_hprc() >>> vp.anno.pl.copy_number_stacked_violin(adata, group_by="haplotype") >>> vp.anno.pl.copy_number_stacked_violin( ... adata, group_by="ancestry", motifs=["0", "1", "2"] ... ) """ import plotly.graph_objects as go from plotly.subplots import make_subplots import numpy as np if group_by is not None and group_by not in adata.obs.columns: raise KeyError(f"group_by column '{group_by}' not found in adata.obs") # Resolve motif list if motifs is None: motif_list = list(adata.var_names) elif isinstance(motifs, str): motif_list = [motifs] else: motif_list = list(motifs) if len(motif_list) == 0: raise ValueError("No motifs selected for plotting.") if len(motif_list) > 30: logger.warning( "Plotting %d motifs; consider passing a smaller subset via " "the `motifs` argument for clarity.", len(motif_list), ) # Resolve motif indices motif_indices: list[int] = [] motif_labels: list[str] = [] for m in motif_list: if isinstance(m, int): if m < 0 or m >= adata.n_vars: raise IndexError(f"motif index {m} out of range") idx = m elif isinstance(m, str): if m in adata.var.index: idx = adata.var.index.get_loc(m) elif "motif" in adata.var.columns and m in adata.var["motif"].values: idx = adata.var["motif"].tolist().index(m) else: raise KeyError( f"motif '{m}' not found in adata.var.index or adata.var['motif']" ) else: raise TypeError(f"motif entries must be str or int, got {type(m).__name__}") motif_indices.append(idx) motif_labels.append(str(m)) # Resolve group order if group_by is None: groups = ["All"] group_labels = np.array(["All"] * adata.n_obs) else: group_labels = adata.obs[group_by].astype(str).to_numpy() all_groups = set(adata.obs[group_by].dropna().unique()) if group_order is not None: groups = list(group_order) missing = set(all_groups) - set(groups) if missing: raise ValueError( f"group_order contains unknown groups: {missing}" ) else: groups = sorted(all_groups, key=str) if len(groups) == 0: raise ValueError( f"No valid groups found in adata.obs['{group_by}']" ) n_motifs = len(motif_indices) n_groups = len(groups) # Pre-compute per-group sample counts group_counts = {} for group in groups: if group_by is None: group_counts[group] = adata.n_obs else: group_counts[group] = int((group_labels == str(group)).sum()) font_size = kwargs.get("font", {}).get("size") if font_size is None: font_size = _sizing.get_active_font_size() width, height = _sizing.resolve_figsize( figsize[0], figsize[1], calc_width=lambda: _sizing.stacked_violin_width(n_groups, font_size), calc_height=lambda: _sizing.stacked_violin_height(n_motifs, row_height, font_size), ) X = adata.X if hasattr(X, "toarray"): X = X.toarray() X = np.asarray(X, dtype=float) if log: X = np.log1p(X) # Resolve colours if colormap is None: color_palette = ["rgb(255, 255, 255)", "rgb(178, 34, 34)"] elif isinstance(colormap, str): try: from plotly.colors import sample_colorscale color_palette = sample_colorscale(colormap, np.linspace(0, 1, 256)) except Exception: color_palette = _SEQUENTIAL_COLORMAP else: color_palette = list(colormap) n_colors = len(color_palette) plotly_colormap = [[i / (n_colors - 1), color_palette[i]] for i in range(n_colors)] # Compute per-motif, per-group medians for colour mapping medians = np.zeros((n_motifs, n_groups), dtype=float) for row_idx, idx in enumerate(motif_indices): y_all = X[:, idx] for col_idx, group in enumerate(groups): if group_by is None: mask = np.ones(adata.n_obs, dtype=bool) else: mask = group_labels == str(group) medians[row_idx, col_idx] = np.median(y_all[mask]) # Normalise medians row-wise (per motif) median_norm = np.zeros_like(medians) for row_idx in range(n_motifs): vmin, vmax = medians[row_idx].min(), medians[row_idx].max() if vmax > vmin: median_norm[row_idx] = (medians[row_idx] - vmin) / (vmax - vmin) else: # use darkest color if all values identical median_norm[row_idx] = 1.0 fig = make_subplots( rows=n_motifs, cols=1, shared_xaxes=True, vertical_spacing=0.01, ) # Add left-side motif labels aligned to each subplot's vertical centre for i, label in enumerate(motif_labels, start=1): yaxis_name = f"yaxis{i}" if i > 1 else "yaxis" y_domain = fig.layout[yaxis_name].domain y_center = (y_domain[0] + y_domain[1]) / 2 # horizontal tick fig.add_shape( type="line", xref="paper", yref="paper", x0=-0.018, x1=-0.002, y0=y_center, y1=y_center, line=dict(width=_sizing.get_active_line_width()), ) # label fig.add_annotation( x=-0.02, y=y_center, xref="paper", yref="paper", text=label, showarrow=False, font=dict(size=_sizing.get_active_font_size()), xanchor="right", yanchor="middle", textangle=0, ) for row_idx, idx in enumerate(motif_indices, start=1): y_all = X[:, idx] for col_idx, group in enumerate(groups): if group_by is None: mask = np.ones(adata.n_obs, dtype=bool) else: mask = group_labels == str(group) group_y = y_all[mask] x_vals = [str(group)] * len(group_y) ratio = median_norm[row_idx - 1, col_idx] color_idx = int(np.round(ratio * (n_colors - 1))) color = color_palette[min(color_idx, n_colors - 1)] trace_kwargs = dict( x=x_vals, y=group_y, name=str(group), showlegend=False, fillcolor=color, line=dict(color="black", width=1), opacity=0.8, width=0.7, spanmode="hard", side="both", box_visible=show_box, box=dict(fillcolor="white", line=dict(color="black", width=1.2)), meanline_visible=True, hovertemplate="%{x}<br>Count: %{y}<extra></extra>", ) if show_points: trace_kwargs["points"] = "all" trace_kwargs["jitter"] = 0.2 trace_kwargs["pointpos"] = 0 trace_kwargs["marker"] = dict( color="white", line=dict(color="black", width=1), size=8, opacity=0.9, ) else: trace_kwargs["points"] = False fig.add_trace(go.Violin(**trace_kwargs), row=row_idx, col=1) # Strip all axes (ticks + lines) globally, then restore bottom x-axis only fig.update_xaxes(showticklabels=False, showline=False, zeroline=False, ticks="") fig.update_yaxes(showticklabels=False, showline=False, zeroline=False, ticks="") fig.update_xaxes( showticklabels=True, showline=True, linecolor="black", ticks="outside", row=n_motifs, col=1, ) if show_counts: fig.update_xaxes( ticktext=[f"{g}<br>(n={group_counts[g]})" for g in groups], tickvals=groups, row=n_motifs, col=1, ) # Global bounding box around the whole plot area fig.add_shape( type="rect", x0=0, y0=0, x1=1, y1=1, xref="paper", yref="paper", line=dict(color="black", width=_sizing.get_active_line_width()), fillcolor="rgba(0,0,0,0)", layer="above", ) # Colour-bar legend for the median-based gradient # Dynamic colorbar y so its top edge stays ~30 px below the plot area # regardless of total figure height. plot_height = max(height - 60 - 100, 1) cb_y = - 70 / plot_height fig.add_trace(go.Scatter( x=[None], y=[None], mode="markers", marker=dict( colorscale=plotly_colormap, showscale=True, cmin=0, cmax=1, colorbar=dict( orientation="h", title=dict(text="Normalized median CN", side="bottom"), thickness=15, len=0.5, xanchor="center", x=0.5, y=cb_y, yanchor="top", ), ), showlegend=False, hoverinfo="skip", )) layout = dict( width=width, height=height, violinmode="group", margin=dict(b=100, t=60, l=120), ) fig.update_layout(**layout, **kwargs) if save: _save_figure(fig, save, "copy_number_stacked_violin") return fig