Skip to content

Commit 5f6f2bf

Browse files
committed
Add expression plotting function and enhance statistical tests
- Introduced `plot_expression` function in `plotting.py` to visualize per-replicate expression values for transcripts based on specified queries. - Updated `__init__.py` to include the new plotting function in the module's exports. - Enhanced `test_allelic_ratios_between_conditions` to skip tests with insufficient ratio statistics. - Improved `test_pairwise_allele_response_between_conditions` to handle missing functional annotations and ensure consistent allele ordering. - Refined `test_isoform_DIU_between_conditions` to check for zero counts before running statistical tests and ensure all isoforms are included in plotting results.
1 parent 07197dd commit 5f6f2bf

4 files changed

Lines changed: 285 additions & 87 deletions

File tree

docs/source/potato_polyase.ipynb

Lines changed: 24 additions & 9 deletions
Large diffs are not rendered by default.

polyase/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@
1212
from .allele_utils import AlleleRatioCalculator, calculate_allelic_ratios
1313
from .multimapping import MultimappingRatioCalculator, calculate_multi_ratios, calculate_per_allele_ratios
1414
from .filter import filter_low_expressed_genes
15-
from .plotting import plot_allelic_ratios, plot_top_differential_syntelogs, plot_top_differential_syntelogs_annotated, plot_top_differential_isoforms, plot_differential_isoform_usage, plot_allele_specific_isoform_structure
15+
from .plotting import plot_allelic_ratios, plot_top_differential_syntelogs, plot_top_differential_syntelogs_annotated, plot_top_differential_isoforms, plot_differential_isoform_usage, plot_allele_specific_isoform_structure, plot_expression
1616
from .ase_data_loader import load_ase_data, aggregate_transcripts_to_genes
1717
from .stats import test_allelic_ratios_between_conditions, test_allelic_ratios_within_conditions, test_allelic_ratios_pairwise, get_top_differential_syntelogs, test_isoform_DIU_between_conditions, test_differential_isoform_structure, test_pairwise_allele_response_between_conditions
1818
from .structure import add_exon_structure, add_structure_from_gtf
1919

20-
__all__ = ["filter_low_expressed_genes","AlleleRatioCalculator", "calculate_allelic_ratios", "MultimappingRatioCalculator", "calculate_multi_ratios","plot_allelic_ratios", "load_ase_data", "aggregate_transcripts_to_genes", "test_allelic_ratios_between_conditions", "test_allelic_ratios_within_conditions", "test_allelic_ratios_pairwise","plot_top_differential_syntelogs", "plot_top_differential_syntelogs_annotated", "plot_top_differential_isoforms", "get_top_differential_syntelogs", "test_isoform_DIU_between_conditions", "calculate_per_allele_ratios", "add_exon_structure", "add_structure_from_gtf", "test_differential_isoform_structure", "plot_differential_isoform_usage", "plot_allele_specific_isoform_structure", "test_pairwise_allele_response_between_conditions"]
20+
__all__ = ["filter_low_expressed_genes","AlleleRatioCalculator", "calculate_allelic_ratios", "MultimappingRatioCalculator", "calculate_multi_ratios","plot_allelic_ratios", "load_ase_data", "aggregate_transcripts_to_genes", "test_allelic_ratios_between_conditions", "test_allelic_ratios_within_conditions", "test_allelic_ratios_pairwise","plot_top_differential_syntelogs", "plot_top_differential_syntelogs_annotated", "plot_top_differential_isoforms", "get_top_differential_syntelogs", "test_isoform_DIU_between_conditions", "calculate_per_allele_ratios", "add_exon_structure", "add_structure_from_gtf", "test_differential_isoform_structure", "plot_differential_isoform_usage", "plot_allele_specific_isoform_structure", "test_pairwise_allele_response_between_conditions", "plot_expression"]

polyase/plotting.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1618,3 +1618,181 @@ def plot_allele_specific_isoform_structure(
16181618

16191619
print(f"Generated {len(figures)} plots for allele-specific isoform structure")
16201620
return figures
1621+
1622+
1623+
def plot_expression(
1624+
adata,
1625+
query,
1626+
id_type='Synt_id',
1627+
layer='unique_counts',
1628+
group_key='condition',
1629+
aggregate=None,
1630+
include=None,
1631+
ax=None,
1632+
figsize=None,
1633+
palette=None,
1634+
jitter=0.2,
1635+
alpha=0.8,
1636+
output_file=None,
1637+
):
1638+
"""
1639+
Plot per-replicate expression values for all transcripts matching a query,
1640+
with isoforms on the x-axis and condition as hue.
1641+
1642+
Parameters
1643+
----------
1644+
adata : AnnData
1645+
AnnData object containing expression data.
1646+
query : str
1647+
Identifier value to look up (e.g. a Synt_id, gene_id, or transcript_id).
1648+
id_type : str, default='Synt_id'
1649+
Where to match ``query``. Use any column name present in ``adata.var``
1650+
('Synt_id', 'gene_id', 'transcript_id', …) or ``'var_names'`` to match
1651+
the AnnData variable index directly.
1652+
layer : str, default='unique_counts'
1653+
Layer to use for expression values.
1654+
group_key : str, default='condition'
1655+
Column in ``adata.obs`` that contains condition labels.
1656+
aggregate : str or None, default=None
1657+
If ``'sum'`` or ``'mean'``, collapse all matching transcripts per sample
1658+
into a single value before plotting (one x-tick for the whole query).
1659+
If ``None``, each transcript is shown as a separate x-tick.
1660+
include : list of str or None, default=None
1661+
Subset of transcript names (``adata.var_names``) to plot. If ``None``,
1662+
all transcripts matching ``query`` are shown.
1663+
ax : matplotlib.axes.Axes or None, default=None
1664+
Existing axes to draw into. When provided, no new figure is created and
1665+
``figsize`` / layout rescaling are ignored — the caller controls layout.
1666+
figsize : tuple or None, default=None
1667+
Figure size. Defaults to ``(max(6, 2 * n_transcripts), 4)`` when
1668+
``aggregate=None``, or ``(4, 4)`` when aggregating.
1669+
palette : dict or None, default=None
1670+
Colour palette mapping condition names to colours.
1671+
jitter : float, default=0.2
1672+
Strip-plot jitter width.
1673+
alpha : float, default=0.8
1674+
Point transparency.
1675+
output_file : str or None, default=None
1676+
If given, save the figure to this path.
1677+
1678+
Returns
1679+
-------
1680+
matplotlib.figure.Figure or None
1681+
"""
1682+
import numpy as np
1683+
import pandas as pd
1684+
import matplotlib.pyplot as plt
1685+
import seaborn as sns
1686+
1687+
# ── validate ─────────────────────────────────────────────────────────────
1688+
if layer not in adata.layers:
1689+
raise ValueError(f"Layer '{layer}' not found in AnnData object.")
1690+
if group_key not in adata.obs:
1691+
raise ValueError(f"'{group_key}' not found in adata.obs.")
1692+
if aggregate is not None and aggregate not in ('sum', 'mean'):
1693+
raise ValueError(f"aggregate must be 'sum', 'mean', or None, got '{aggregate}'.")
1694+
1695+
# ── find matching transcripts ────────────────────────────────────────────
1696+
if id_type == 'var_names':
1697+
mask = adata.var_names == query
1698+
elif id_type in adata.var.columns:
1699+
mask = adata.var[id_type] == query
1700+
else:
1701+
raise ValueError(
1702+
f"id_type '{id_type}' is not 'var_names' and not a column in adata.var. "
1703+
f"Available columns: {list(adata.var.columns)}"
1704+
)
1705+
1706+
indices = np.where(mask)[0]
1707+
if len(indices) == 0:
1708+
print(f"No transcripts found for {id_type}='{query}'.")
1709+
return None
1710+
1711+
if include is not None:
1712+
include_set = set(include)
1713+
indices = np.array([i for i in indices if adata.var_names[i] in include_set])
1714+
if len(indices) == 0:
1715+
print(f"No transcripts remain after filtering with include={include}.")
1716+
return None
1717+
1718+
# ── build tidy dataframe ─────────────────────────────────────────────────
1719+
counts = adata.layers[layer]
1720+
if hasattr(counts, 'toarray'):
1721+
counts = counts.toarray()
1722+
1723+
conditions = adata.obs[group_key].values
1724+
sample_names = adata.obs_names.tolist()
1725+
1726+
rows_list = []
1727+
for pos in indices:
1728+
transcript_name = adata.var_names[pos]
1729+
for sample_idx, (sample, cond) in enumerate(zip(sample_names, conditions)):
1730+
rows_list.append({
1731+
'transcript': transcript_name,
1732+
'sample': sample,
1733+
'condition': cond,
1734+
'value': counts[sample_idx, pos],
1735+
})
1736+
1737+
plot_df = pd.DataFrame(rows_list)
1738+
1739+
# ── aggregate across transcripts if requested ────────────────────────────
1740+
if aggregate is not None:
1741+
agg_fn = plot_df.groupby(['sample', 'condition'])['value']
1742+
plot_df = (agg_fn.sum() if aggregate == 'sum' else agg_fn.mean()).reset_index()
1743+
plot_df['transcript'] = query
1744+
y_label = f"{layer.replace('_', ' ')} ({aggregate})"
1745+
else:
1746+
y_label = layer.replace('_', ' ')
1747+
1748+
# when aggregated: condition goes on x-axis; otherwise transcripts on x with condition as hue
1749+
if aggregate is not None:
1750+
x_col, hue_col, dodge = 'condition', None, False
1751+
n_x = plot_df['condition'].nunique()
1752+
else:
1753+
x_col, hue_col, dodge = 'transcript', 'condition', True
1754+
n_x = plot_df['transcript'].nunique()
1755+
1756+
# ── figure ───────────────────────────────────────────────────────────────
1757+
own_figure = ax is None
1758+
if own_figure:
1759+
# figsize = desired PLOT AREA size; total figure expands to fit labels/legend
1760+
if figsize is None:
1761+
figsize = (max(4, 1.5 * n_x), 4) if aggregate is not None else (max(6, 2 * n_x), 4)
1762+
fig, ax = plt.subplots()
1763+
else:
1764+
fig = ax.get_figure()
1765+
1766+
sns.boxplot(
1767+
data=plot_df, x=x_col, y='value', hue=hue_col,
1768+
palette=palette, ax=ax, linewidth=1.5,
1769+
fliersize=0, width=0.5,
1770+
)
1771+
sns.stripplot(
1772+
data=plot_df, x=x_col, y='value', hue=hue_col,
1773+
ax=ax, color='black',
1774+
jitter=jitter, alpha=alpha, size=6,
1775+
dodge=dodge, legend=False,
1776+
)
1777+
1778+
ax.set_title(f"{query}", fontsize=10)
1779+
ax.set_xlabel('')
1780+
ax.set_ylabel(y_label)
1781+
ax.tick_params(axis='x', rotation=45)
1782+
if aggregate is None:
1783+
ax.legend(title=group_key, bbox_to_anchor=(1.01, 1), loc='upper left', borderaxespad=0)
1784+
1785+
if own_figure:
1786+
# Rescale figure so the axis is exactly figsize (labels expand figure, not shrink plot).
1787+
fig.tight_layout(pad=1.0)
1788+
fig_w, fig_h = fig.get_size_inches()
1789+
ax_pos = ax.get_position()
1790+
fig.set_size_inches(
1791+
fig_w * figsize[0] / (fig_w * ax_pos.width),
1792+
fig_h * figsize[1] / (fig_h * ax_pos.height),
1793+
)
1794+
1795+
if output_file:
1796+
fig.savefig(output_file, bbox_inches='tight')
1797+
1798+
return fig

0 commit comments

Comments
 (0)