Source code for scplotkit.sankey

"""Sankey diagrams, sunbursts and treemaps showing how cells flow through /
nest within a hierarchy of annotation levels (e.g. Level_1 -> Level_4)."""

from __future__ import annotations

from collections import defaultdict
from pathlib import Path

from matplotlib.colors import to_hex, to_rgb

from ._config import PlotConfig
from ._palettes import resolve_palette
from ._utils import require_columns

__all__ = [
    "hierarchical_counts",
    "hierarchical_counts_string",
    "sankey_plot",
    "sunburst_plot",
    "treemap_plot",
]


[docs] def hierarchical_counts(adata, levels: list[str]) -> list[str]: """Return indented ``"label [count]"`` lines describing cell counts nested across 1-3 annotation ``levels`` (coarse to fine).""" if not levels or len(levels) > 3: raise ValueError("Provide between 1 and 3 levels.") require_columns(adata, *levels) obs_df = adata.obs if len(levels) == 1: hierarchy = defaultdict(int) elif len(levels) == 2: hierarchy = defaultdict(lambda: defaultdict(int)) else: hierarchy = defaultdict(lambda: defaultdict(lambda: defaultdict(int))) for _, row in obs_df.iterrows(): if len(levels) == 1: hierarchy[row[levels[0]]] += 1 elif len(levels) == 2: hierarchy[row[levels[0]]][row[levels[1]]] += 1 else: hierarchy[row[levels[0]]][row[levels[1]]][row[levels[2]]] += 1 result_list = [] if len(levels) == 1: for level1, count in hierarchy.items(): result_list.append(f"{level1} [{count}]") elif len(levels) == 2: for level1, level2_dict in hierarchy.items(): result_list.append(f"{level1} [{sum(level2_dict.values())}]") for level2, count in level2_dict.items(): result_list.append(f" {level2} [{count}]") else: for level1, level2_dict in hierarchy.items(): level1_count = sum(sum(d.values()) for d in level2_dict.values()) result_list.append(f"{level1} [{level1_count}]") for level2, level3_dict in level2_dict.items(): result_list.append(f" {level2} [{sum(level3_dict.values())}]") for level3, count in level3_dict.items(): result_list.append(f" {level3} [{count}]") return result_list
[docs] def hierarchical_counts_string(adata, levels: list[str]) -> str: """Newline-joined version of :func:`hierarchical_counts`.""" return "\n".join(hierarchical_counts(adata, levels))
[docs] def sankey_plot( adata, levels: list[str], save_name: str | None = None, width: int = 1100, height: int = 800, show_labels: bool = True, node_color: str = "grey", link_color: str = "lightgrey", config: PlotConfig | None = None, output_dir: str | Path | None = "figures", ): """Sankey diagram of cell flow across 1-3 nested annotation ``levels``. Requires the optional ``sankey`` extra (``pip install scplotkit[sankey]``) for ``plotly``/``kaleido``. """ try: import plotly.graph_objects as go except ImportError as e: raise ImportError( "sankey_plot requires plotly (and kaleido to save images). " "Install with `pip install scplotkit[sankey]`." ) from e config = PlotConfig.load(config) hierarchy_string = hierarchical_counts_string(adata, levels) labels, sources, targets, values = [], [], [], [] label_map: dict[str, int] = {} def label_index(label: str) -> int: if label not in label_map: label_map[label] = len(labels) labels.append(label) return label_map[label] parent_stack: list[str] = [] for line in hierarchy_string.strip().split("\n"): indent = len(line) - len(line.lstrip()) name, count = line.strip().rsplit(" [", 1) value = int(count[:-1]) level = indent // 2 parent_stack = parent_stack[:level] parent_stack.append(name) if level > 0: sources.append(label_index(parent_stack[level - 1])) targets.append(label_index(parent_stack[level])) values.append(value) else: label_index(name) node_kwargs = dict(pad=15, thickness=20, color=node_color) if show_labels: node_kwargs["label"] = labels fig = go.Figure( data=[go.Sankey(node=node_kwargs, link=dict(source=sources, target=targets, value=values, color=link_color))] ) fig.update_layout(width=width, height=height, font_size=12, margin=dict(l=50, r=50, t=50, b=50)) fig.data[0].arrangement = "snap" if save_name is not None: save_dir = Path(output_dir) / "sankey_plot" save_dir.mkdir(parents=True, exist_ok=True) save_path = save_dir / f"{save_name}.png" fig.write_image(str(save_path), width=width, height=height, scale=2) print(f"Figure saved to: {save_path}") return fig
def _hierarchy_nodes(adata, levels: list[str]) -> tuple[list[str], list[str], list[str], list[int], list[int]]: """Build ``(ids, labels, parents, values, depths)`` for every node across nested ``levels``. Each node id is the full ``level1/level2/...`` path rather than the bare category name, so two branches that happen to share a child label (e.g. an "Other" under both "Myeloid" and "Lymphoid") stay distinct nodes instead of silently merging. """ require_columns(adata, *levels) counts = adata.obs.groupby(list(levels), observed=True).size() value_by_id: dict[str, int] = {} parent_by_id: dict[str, str] = {} label_by_id: dict[str, str] = {} depth_by_id: dict[str, int] = {} order: list[str] = [] for key, count in counts.items(): key_tuple = key if isinstance(key, tuple) else (key,) parent_id = "" for depth, name in enumerate(key_tuple): node_id = f"{parent_id}/{name}" if parent_id else str(name) if node_id not in value_by_id: value_by_id[node_id] = 0 parent_by_id[node_id] = parent_id label_by_id[node_id] = str(name) depth_by_id[node_id] = depth order.append(node_id) value_by_id[node_id] += count parent_id = node_id labels = [label_by_id[i] for i in order] parents = [parent_by_id[i] for i in order] values = [value_by_id[i] for i in order] depths = [depth_by_id[i] for i in order] return order, labels, parents, values, depths def _lighten(color, amount: float) -> str: """Blend ``color`` toward white by ``amount`` (0 = unchanged, 1 = white).""" r, g, b = to_rgb(color) return to_hex((r + (1 - r) * amount, g + (1 - g) * amount, b + (1 - b) * amount)) def _hierarchy_colors( ids: list[str], depths: list[int], config: PlotConfig, level0_column: str, palette: dict | None ) -> list[str]: """One color per node: a categorical color per top-level branch, lightened with depth.""" top_level_names = [ids[i].split("/", 1)[0] for i, d in enumerate(depths) if d == 0] base_colors = resolve_palette(config, level0_column, top_level_names, palette) return [ _lighten(base_colors[node_id.split("/", 1)[0]], min(depth, 4) * 0.16) for node_id, depth in zip(ids, depths) ] def _hierarchy_figure( build_figure, adata, levels: list[str], palette: dict | None, title: str | None, width: int, height: int, config: PlotConfig | None, extra_trace_kwargs: dict, ): config = PlotConfig.load(config) if not levels or len(levels) > 4: raise ValueError("Provide between 1 and 4 levels.") ids, labels, parents, values, depths = _hierarchy_nodes(adata, levels) colors = _hierarchy_colors(ids, depths, config, levels[0], palette) fig = build_figure( ids=ids, labels=labels, parents=parents, values=values, branchvalues="total", marker=dict(colors=colors, line=dict(color="white", width=1)), **extra_trace_kwargs, ) general = config.general fig.update_layout( width=width, height=height, title=dict(text=title, font=dict(size=general["title_fontsize"], family=general["font_family"])), font=dict(size=general["legend_fontsize"], family=general["font_family"]), margin=dict(l=10, r=10, t=50 if title else 10, b=10), ) return fig
[docs] def sunburst_plot( adata, levels: list[str], title: str | None = None, save_name: str | None = None, width: int = 900, height: int = 900, palette: dict | None = None, config: PlotConfig | None = None, output_dir: str | Path | None = "figures", ): """Sunburst of a nested cell-type taxonomy (e.g. ``["Level_1", ..., "Level_4"]``), an alternative to :func:`sankey_plot` for seeing the whole tree at a glance. Ring 1 (innermost) is colored by ``levels[0]`` using the config's registered palette for that column (falling back to an auto-generated one); deeper rings reuse their top-level ancestor's color, progressively lightened by depth. Purely descriptive -- segment size is raw cell count, no statistical framing. Requires the optional ``sankey`` extra (``pip install scplotkit[sankey]``) for ``plotly``/``kaleido``. """ try: import plotly.graph_objects as go except ImportError as e: raise ImportError( "sunburst_plot requires plotly (and kaleido to save images). " "Install with `pip install scplotkit[sankey]`." ) from e fig = _hierarchy_figure( lambda **kw: go.Figure(data=[go.Sunburst(**kw)]), adata, levels, palette, title, width, height, config, extra_trace_kwargs=dict(textinfo="label+percent parent"), ) if save_name is not None: save_dir = Path(output_dir) / "sunburst_plot" save_dir.mkdir(parents=True, exist_ok=True) save_path = save_dir / f"{save_name}.png" fig.write_image(str(save_path), width=width, height=height, scale=2) print(f"Figure saved to: {save_path}") return fig
[docs] def treemap_plot( adata, levels: list[str], title: str | None = None, save_name: str | None = None, width: int = 1100, height: int = 700, palette: dict | None = None, config: PlotConfig | None = None, output_dir: str | Path | None = "figures", ): """Treemap of a nested cell-type taxonomy (e.g. ``["Level_1", ..., "Level_4"]``), an alternative to :func:`sankey_plot` -- rectangle area makes relative size easier to compare at a glance than a sunburst's arc angles, especially with many leaf categories. Coloring follows the same rule as :func:`sunburst_plot`: top-level branches take the config's palette for ``levels[0]``, descendants reuse their ancestor's color lightened by depth. Purely descriptive -- tile area is raw cell count, no statistical framing. Requires the optional ``sankey`` extra (``pip install scplotkit[sankey]``) for ``plotly``/``kaleido``. """ try: import plotly.graph_objects as go except ImportError as e: raise ImportError( "treemap_plot requires plotly (and kaleido to save images). " "Install with `pip install scplotkit[sankey]`." ) from e fig = _hierarchy_figure( lambda **kw: go.Figure(data=[go.Treemap(**kw)]), adata, levels, palette, title, width, height, config, extra_trace_kwargs=dict(textinfo="label+value", tiling=dict(packing="squarify")), ) if save_name is not None: save_dir = Path(output_dir) / "treemap_plot" save_dir.mkdir(parents=True, exist_ok=True) save_path = save_dir / f"{save_name}.png" fig.write_image(str(save_path), width=width, height=height, scale=2) print(f"Figure saved to: {save_path}") return fig