@@ -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