Source code for vampire.anno.pl._pca

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 _COLORMAP_OPTIONS # dict[str, list[str] | dict[str, str]]


[docs] def motif_abundance_pca( adata: "ad.AnnData", color_by: str | None = None, shape_by: str | None = None, components: tuple[int, int] = (1, 2), figsize: tuple[int | None, int | None] = (None, None), title: str | None = None, marker_size: int = 10, colormap: str | list[str] | None = None, show_variance: bool = True, save: str | bool | None = None, **kwargs, ) -> "go.Figure": """Plot pairwise principal components from motif abundance PCA. Reads pre-computed PCA results stored by ``vp.anno.tl.motif_abundance_pca()``. Color and marker shape can be mapped to columns in ``adata.obs``. Parameters ---------- adata : ad.AnnData Annotated data with PCA results from ``vp.anno.tl.motif_abundance_pca()``. color_by : str, optional Column in ``adata.obs`` for marker color. Categorical columns use a discrete palette; numeric columns use a continuous colorscale. shape_by : str, optional Column in ``adata.obs`` for marker shape. Must be categorical. components : tuple[int, int], default=(1, 2) Which two PCs to plot. 1-based indexing, e.g. ``(1, 2)`` for PC1 vs PC2, ``(2, 3)`` for PC2 vs PC3. figsize : tuple[int | None, int | None], default=(None, None) Figure size in pixels. title : str | None, default=None Plot title. marker_size : int, default=10 Marker size. colormap : str | list[str] | None, default=None Plotly colormap name for numeric ``color_by``. Defaults to ``"Viridis"``. show_variance : bool, default=True Append explained-variance percentages to axis titles. **kwargs Additional keyword arguments passed to ``fig.update_layout()``. 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'``}. Returns ------- go.Figure Plotly scatter figure of the chosen PCs. Examples -------- >>> import vampire as vp >>> vp.anno.pl.set_default_plotstyle() >>> adata = vp.datasets.wdr7_hprc() >>> vp.anno.tl.motif_abundance_pca(adata) >>> vp.anno.pl.motif_abundance_pca(adata, color_by="copy_number", components=(1, 2)) """ import pandas as pd import plotly.graph_objects as go # ---- read pre-computed PCA results ---- pc_mat = adata.obsm.get("X_motif_abundance_pca") if pc_mat is None: raise KeyError( "PCA results not found at obsm['X_motif_abundance_pca']. " "Run vp.anno.tl.motif_abundance_pca() first." ) n_pcs = pc_mat.shape[1] x_idx = components[0] - 1 y_idx = components[1] - 1 if x_idx < 0 or y_idx < 0 or x_idx >= n_pcs or y_idx >= n_pcs: raise ValueError( f"components={components} out of range. " f"Only {n_pcs} PCs available (use 1-based indexing)." ) pca_info = adata.uns.get("motif_abundance_pca", {}) evr = pca_info.get("variance_ratio", []) pc_x = pc_mat[:, x_idx] pc_y = pc_mat[:, y_idx] pc_x_name = f"PC{components[0]}" pc_y_name = f"PC{components[1]}" # ---- colour mapping ---- color_series = None color_is_numeric = False color_map: dict[str, str] | None = None if color_by is not None: if color_by not in adata.obs.columns: raise KeyError(f"color_by column '{color_by}' not found in adata.obs") color_series = adata.obs[color_by] color_is_numeric = pd.api.types.is_numeric_dtype(color_series) if not color_is_numeric: color_map = { str(v): _RAINBOW_COLORMAP[i % len(_RAINBOW_COLORMAP)] for i, v in enumerate(sorted(set(color_series.dropna().astype(str)))) } # ---- shape mapping ---- shape_series = None shape_map: dict[str, str] | None = None _SYMBOLS = [ "circle", "square", "diamond", "cross", "x", "triangle-up", "triangle-down", "star", "hexagon", "pentagon", "octagon", "star-triangle-up", "star-square", "diamond-tall", "diamond-wide", "hourglass", "bowtie", "circle-cross", "square-cross", "triangle-left", "triangle-right", ] if shape_by is not None: if shape_by not in adata.obs.columns: raise KeyError(f"shape_by column '{shape_by}' not found in adata.obs") shape_series = adata.obs[shape_by] shape_map = { str(v): _SYMBOLS[i % len(_SYMBOLS)] for i, v in enumerate(sorted(set(shape_series.dropna().astype(str)))) } # ---- build traces ---- fig = go.Figure() def _make_hover(fmt: str) -> str: return fmt.replace("{pc_x}", pc_x_name).replace("{pc_y}", pc_y_name) def _add_default_trace(): fig.add_trace(go.Scatter( x=pc_x, y=pc_y, mode="markers", marker=dict(size=marker_size, color="#277da1", line=dict(width=1, color="DarkSlateGrey")), hovertemplate=_make_hover( "Sample: %{text}<br>{pc_x}: %{x:.2f}<br>{pc_y}: %{y:.2f}<extra></extra>" ), text=adata.obs.index, name="Samples", )) if color_by is None and shape_by is None: _add_default_trace() elif color_by is not None and shape_by is None: if color_is_numeric: fig.add_trace(go.Scatter( x=pc_x, y=pc_y, mode="markers", marker=dict( size=marker_size, color=color_series, colorscale=colormap or "Viridis", colorbar=dict(title=str(color_by)), line=dict(width=1, color="DarkSlateGrey"), ), hovertemplate=_make_hover( "Sample: %{text}<br>{pc_x}: %{x:.2f}<br>{pc_y}: %{y:.2f}<br>" + f"{color_by}: %{{marker.color:.2f}}<extra></extra>" ), text=adata.obs.index, name="Samples", )) else: for val in sorted(color_map.keys()): # type: ignore[union-attr] mask = color_series.astype(str) == val # type: ignore[union-attr] if mask.sum() == 0: continue fig.add_trace(go.Scatter( x=pc_x[mask], y=pc_y[mask], mode="markers", marker=dict(size=marker_size, color=color_map[val], # type: ignore[index] line=dict(width=1, color="DarkSlateGrey")), hovertemplate=_make_hover( "Sample: %{text}<br>{pc_x}: %{x:.2f}<br>{pc_y}: %{y:.2f}<extra></extra>" ), text=adata.obs.index[mask], name=str(val), )) elif color_by is None and shape_by is not None: for val in sorted(shape_map.keys()): # type: ignore[union-attr] mask = shape_series.astype(str) == val # type: ignore[union-attr] if mask.sum() == 0: continue fig.add_trace(go.Scatter( x=pc_x[mask], y=pc_y[mask], mode="markers", marker=dict(size=marker_size, symbol=shape_map[val], # type: ignore[index] color="#277da1", line=dict(width=1, color="DarkSlateGrey")), hovertemplate=_make_hover( "Sample: %{text}<br>{pc_x}: %{x:.2f}<br>{pc_y}: %{y:.2f}<extra></extra>" ), text=adata.obs.index[mask], name=str(val), )) else: # both color_by and shape_by if color_is_numeric: sym_array = [shape_map.get(str(v), "circle") for v in shape_series] # type: ignore[union-attr] fig.add_trace(go.Scatter( x=pc_x, y=pc_y, mode="markers", marker=dict( size=marker_size, color=color_series, colorscale=colormap or "Viridis", colorbar=dict(title=str(color_by)), symbol=sym_array, line=dict(width=1, color="DarkSlateGrey"), ), hovertemplate=_make_hover( "Sample: %{text}<br>{pc_x}: %{x:.2f}<br>{pc_y}: %{y:.2f}<br>" + f"{color_by}: %{{marker.color:.2f}}<extra></extra>" ), text=adata.obs.index, name="Samples", )) for sval in sorted(shape_map.keys()): # type: ignore[union-attr] fig.add_trace(go.Scatter( x=[None], y=[None], mode="markers", marker=dict(size=marker_size, symbol=shape_map[sval], # type: ignore[index] color="gray"), name=str(sval), showlegend=True, )) else: for cval in sorted(color_map.keys()): # type: ignore[union-attr] for sval in sorted(shape_map.keys()): # type: ignore[union-attr] mask = ( (color_series.astype(str) == cval) # type: ignore[union-attr] & (shape_series.astype(str) == sval) # type: ignore[union-attr] ) if mask.sum() == 0: continue fig.add_trace(go.Scatter( x=pc_x[mask], y=pc_y[mask], mode="markers", marker=dict( size=marker_size, color=color_map[cval], # type: ignore[index] symbol=shape_map[sval], # type: ignore[index] line=dict(width=1, color="DarkSlateGrey"), ), hovertemplate=_make_hover( "Sample: %{text}<br>{pc_x}: %{x:.2f}<br>{pc_y}: %{y:.2f}<extra></extra>" ), text=adata.obs.index[mask], name=f"{cval} | {sval}", )) # ---- layout ---- x_title, y_title = pc_x_name, pc_y_name if show_variance: if len(evr) > x_idx: x_title += f" ({evr[x_idx] * 100:.1f}%)" if len(evr) > y_idx: y_title += f" ({evr[y_idx] * 100:.1f}%)" fig.update_layout( title=title, xaxis_title=x_title, yaxis_title=y_title, width=700 if figsize[0] is None else figsize[0], height=700 if figsize[1] is None else figsize[1], legend=dict(orientation="h", yanchor="top", y=-0.15, xanchor="center", x=0.5), **kwargs, ) fig.update_xaxes(showline=True, linecolor="black", ticks="outside") fig.update_yaxes(showline=True, linecolor="black", ticks="outside") if save: _save_figure(fig, save, "motif_abundance_pca") return fig
[docs] def motif_abundance_pca_variance( adata: "ad.AnnData", n_pcs: int | None = None, log: bool = False, show_cumulative: bool = True, figsize: tuple[int | None, int | None] = (None, None), title: str | None = None, save: str | bool | None = None, **kwargs, ) -> "go.Figure": """Plot variance explained by each principal component. Reads pre-computed results from ``vp.anno.tl.motif_abundance_pca()`` stored in ``uns['motif_abundance_pca']['variance_ratio']``. Parameters ---------- adata : ad.AnnData Annotated data with PCA results. n_pcs : int, optional Number of PCs to display. If ``None``, display all. log : bool, default=False Use log scale for the variance-ratio axis. show_cumulative : bool, default=True Overlay a cumulative-variance line on the same y-axis. figsize : tuple[int | None, int | None], default=(None, None) to use (700, 600) Figure size in pixels. title : str | None, default=None Plot title. **kwargs Additional keyword arguments passed to ``fig.update_layout()``. 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'``}. Returns ------- go.Figure Bar + line plot of per-PC variance ratios. Examples -------- >>> import vampire as vp >>> vp.anno.pl.set_default_plotstyle() >>> adata = vp.datasets.wdr7_hprc() >>> vp.anno.tl.motif_abundance_pca(adata) >>> vp.anno.pl.motif_abundance_pca_variance(adata) """ import numpy as np import plotly.graph_objects as go pca_info = adata.uns.get("motif_abundance_pca") if pca_info is None: raise KeyError( "PCA results not found. Run vp.anno.tl.motif_abundance_pca() first." ) vr = np.array(pca_info.get("variance_ratio", [])) if len(vr) == 0: raise ValueError("No variance_ratio data found.") if n_pcs is not None: vr = vr[:n_pcs] x = [f"PC{i + 1}" for i in range(len(vr))] fig = go.Figure() # Individual variance ratio (bar) fig.add_trace(go.Bar( x=x, y=vr, name="Individual", marker_color="#277da1", hovertemplate="%{x}<br>Variance: %{y:.4f}<extra></extra>", )) # Cumulative variance (line) if show_cumulative: cumsum = np.cumsum(vr) fig.add_trace(go.Scatter( x=x, y=cumsum, name="Cumulative", mode="lines+markers", line=dict(color="#f94144", width=2), marker=dict(size=6), hovertemplate="%{x}<br>Cumulative: %{y:.4f}<extra></extra>", )) yaxis_type = "log" if log else "linear" layout = dict( title=title, xaxis_title="Principal Component", yaxis=dict( range=[0, 1], title="Explained Variance Ratio", type=yaxis_type ), width=700 if figsize[0] is None else figsize[0], height=600 if figsize[1] is None else figsize[1], legend=dict(orientation="h", yanchor="top", y=-0.15, xanchor="center", x=0.5), ) fig.update_layout(**layout, **kwargs) fig.update_xaxes(showline=True, linecolor="black", ticks="outside") fig.update_yaxes(showline=True, linecolor="black", ticks="outside") if save: _save_figure(fig, save, "motif_abundance_pca_variance") return fig