"""Plots for single-cell heterogeneity."""
from __future__ import annotations
from typing import TYPE_CHECKING
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from mantispy._core._reduce import get_matrix, group_codes
from mantispy._core._utils import as_frame
from mantispy.pl._common import axes as _axes
from mantispy.pl._common import table as _table
if TYPE_CHECKING:
from anndata import AnnData
#: Fixed phase colors, so each phase has the same color in every figure.
PHASE_COLOURS = {"G1": "tab:blue", "S": "tab:grey", "G2M": "tab:red"}
[docs]
def cluster_composition(composition: AnnData, groupby: str = "Metadata_Perturbation", ax: plt.Axes | None = None):
"""Stacked bars of cell-state fractions, averaged within each group.
Takes the object :func:`~mantispy.tl.cluster_composition` returns.
"""
if groupby not in composition.obs:
raise KeyError(f"obs has no column {groupby!r}")
fractions = get_matrix(composition).astype(float)
groups = as_frame(composition.obs)[groupby].astype(str).to_numpy()
labels = list(dict.fromkeys(groups))
means = np.stack([np.nanmean(fractions[groups == group], axis=0) for group in labels])
ax = _axes(ax, (0.4 * len(labels) + 3, 4))
bottom = np.zeros(len(labels))
colours = plt.get_cmap("tab20")
for index, cluster in enumerate(composition.var_names):
ax.bar(np.arange(len(labels)), means[:, index], bottom=bottom, color=colours(index % 20), label=str(cluster))
bottom += means[:, index]
ax.set_xticks(np.arange(len(labels)))
ax.set_xticklabels(labels, rotation=90, fontsize=6)
ax.set_ylabel("fraction of cells")
ax.legend(fontsize=5, ncol=2, bbox_to_anchor=(1.01, 1), loc="upper left", title="cluster")
return ax
[docs]
def cell_cycle(
adata: AnnData,
dna_feature: str,
by: str | None = "Metadata_Plate",
key: str = "Metadata_CellCyclePhase",
layer: str | None = None,
):
"""Log DNA intensity per group, colored by assigned phase.
Two separated peaks with the phases split between them indicate a working assignment; a
single broad distribution indicates a failed one.
"""
if key not in adata.obs:
raise KeyError(f"obs has no column {key!r}; run mt.tl.cell_cycle_phase first")
values = get_matrix(adata, layer)[:, adata.var_names.get_loc(dna_feature)].astype(float)
positive = np.isfinite(values) & (values > 0)
phases = as_frame(adata.obs)[key].astype(str).to_numpy()
codes, keys = group_codes(adata, by)
figure, axes = plt.subplots(1, len(keys), figsize=(3.2 * len(keys), 3), squeeze=False, sharex=True)
for index, name in enumerate(keys):
axis = axes[0, index]
rows = np.flatnonzero((codes == index) & positive)
for phase, colour in PHASE_COLOURS.items():
selected = values[rows[phases[rows] == phase]]
if selected.size:
axis.hist(np.log(selected), bins=40, histtype="stepfilled", alpha=0.6, color=colour, label=phase)
axis.set_title(str(name), fontsize=8)
axis.set_xlabel("log DNA intensity")
axes[0, 0].legend(fontsize=6)
figure.tight_layout()
return axes
[docs]
def subpopulation_hits(adata: AnnData, key: str = "subpopulation_hits", top: int = 30, ax: plt.Axes | None = None):
"""Cluster by group heatmap of significance, so an effect in one cell state stands out."""
table = _table(adata, key, "mt.tl.subpopulation_hits")
if table.empty:
raise ValueError(f"uns['mantispy'][{key!r}] is empty; no cluster held both controls and another group")
table = table.assign(significance=-np.log10(np.clip(table["qvalue"].to_numpy(dtype=float), 1e-12, None)))
grid = table.pivot_table(index="cluster", columns="group", values="significance", aggfunc="max")
keep = grid.max(axis=0).nlargest(min(top, grid.shape[1])).index
grid = grid[sorted(keep)]
ax = _axes(ax, (0.3 * grid.shape[1] + 3, 0.3 * grid.shape[0] + 2))
image = ax.imshow(grid.to_numpy(dtype=float), aspect="auto", cmap="magma")
ax.set_xticks(np.arange(grid.shape[1]))
ax.set_xticklabels(grid.columns, rotation=90, fontsize=6)
ax.set_yticks(np.arange(grid.shape[0]))
ax.set_yticklabels(grid.index, fontsize=6)
ax.set_ylabel("cluster")
ax.figure.colorbar(image, ax=ax, label="-log10 q")
return ax
[docs]
def density(
adata: AnnData,
feature: str,
groupby: str = "Metadata_Perturbation",
key: str = "Metadata_LocalDensity",
max_groups: int = 6,
ax: plt.Axes | None = None,
):
"""Local cell density against a feature, per group.
Crowding alone changes morphology. Use this plot to check whether density explains a
phenotype before regressing it out with :func:`~mantispy.pp.regress_out`. A group whose
points fall on the same line as the controls differs from them only in density.
Args:
adata: Single-cell object carrying the density column and the feature.
feature: Feature to plot against density.
groupby: Column defining the groups drawn.
key: ``obs`` column holding the local density.
max_groups: Number of groups drawn, largest first.
ax: Axes to draw on.
Returns:
The axes. The legend gives each group's Pearson correlation between density and the
feature.
"""
if key not in adata.obs:
raise KeyError(f"obs has no column {key!r}; run mt.tl.neighbors_local_density first")
obs = as_frame(adata.obs)
values = get_matrix(adata)[:, adata.var_names.get_loc(feature)].astype(float)
crowding = obs[key].to_numpy(dtype=float)
groups = obs[groupby].astype(str).to_numpy()
ax = _axes(ax, (5.5, 4.5))
for name in pd.Series(groups).value_counts().index[:max_groups]:
rows = np.flatnonzero((groups == name) & np.isfinite(crowding) & np.isfinite(values))
if rows.size < 3:
continue
correlation = float(np.corrcoef(crowding[rows], values[rows])[0, 1])
points = ax.scatter(crowding[rows], values[rows], s=6, alpha=0.5, label=f"{name} (r = {correlation:+.2f})")
slope, intercept = np.polyfit(crowding[rows], values[rows], 1)
grid = np.linspace(crowding[rows].min(), crowding[rows].max(), 2)
ax.plot(grid, slope * grid + intercept, lw=1.2, color=points.get_facecolor()[0])
ax.set_xlabel("mean distance to the k nearest cells in the field")
ax.set_ylabel(feature)
ax.legend(fontsize=6)
return ax