Skip to content

Commit 51cfe6b

Browse files
committed
feat: add marker_profiles function
This plots markers with their mean profiles in a single plot
1 parent d23f6e0 commit 51cfe6b

File tree

4 files changed

+216
-1
lines changed

4 files changed

+216
-1
lines changed

docs/source/api/plotting.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Many of [Scanpy's plotting](https://scanpy.readthedocs.io/en/stable/api/plotting
2323
2424
pl.highly_variable_proteins
2525
pl.bait_volcano_plots
26+
pl.marker_profiles
2627
pl.marker_profiles_split
2728
pl.protein_clustermap
2829
pl.sample_heatmap

grassp/plotting/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,10 @@
22
from .clustering import knn_violin, tagm_map_contours, tagm_map_pca_ellipses
33
from .heatmaps import protein_clustermap, qsep_boxplot, qsep_heatmap, sample_heatmap
44
from .integration import aligned_umap, mr_plot, remodeling_sankey, remodeling_score
5-
from .qc import bait_volcano_plots, highly_variable_proteins, marker_profiles_split
5+
from .qc import (
6+
bait_volcano_plots,
7+
highly_variable_proteins,
8+
marker_profiles,
9+
marker_profiles_split,
10+
)
611
from .ternary import ternary

grassp/plotting/qc.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,3 +413,127 @@ def marker_profiles_split(
413413
if show:
414414
return None
415415
return axs
416+
417+
418+
def marker_profiles(
419+
adata: AnnData,
420+
marker_column: str,
421+
plot_nan: bool = False,
422+
error_type: str = 'std',
423+
xticklabels: bool = False,
424+
ylabel: str = 'Abundance',
425+
replicate_column: str | None = None,
426+
show: bool = True,
427+
save: bool | str | None = None,
428+
) -> plt.Axes | None:
429+
"""Plot mean profiles with error bands for each marker annotation.
430+
431+
Creates a single plot showing the mean profile for each marker category
432+
with shaded error regions (standard deviation or standard error).
433+
434+
This function assumes that ``adata.var`` is sorted by Replicate (if present)
435+
and Fraction/Pulldown, so that related measurements are adjacent on the x-axis.
436+
437+
Parameters
438+
----------
439+
adata
440+
AnnData object with proteins in `.var` and samples/fractions in `.obs`.
441+
marker_column
442+
Column name in ``adata.obs`` containing marker annotations.
443+
plot_nan
444+
If ``True``, NaN entries in the marker column are included;
445+
otherwise they are skipped.
446+
error_type
447+
Type of error to display: ``'std'`` for standard deviation or
448+
``'sem'`` for standard error of the mean. Default is ``'std'``.
449+
xticklabels
450+
If ``True``, label x-ticks with ``adata.var_names``.
451+
ylabel
452+
Label for the y-axis. Default is ``'Abundance'``.
453+
replicate_column
454+
Column name in ``adata.var`` indicating replicate groups. If provided,
455+
vertical dashed lines are drawn at replicate boundaries (after the last
456+
instance of each replicate).
457+
show
458+
If ``True`` (default) the plot is shown and the function returns ``None``.
459+
save
460+
If ``True`` or a ``str``, save the figure. A string is appended to the default filename.
461+
Infer the filetype if ending on ``{'.pdf', '.png', '.svg'}``.
462+
463+
Returns
464+
-------
465+
Returns the Axes if ``show`` is ``False``, otherwise ``None``.
466+
467+
See Also
468+
--------
469+
marker_profiles_split : Plot individual profiles in separate subplots.
470+
"""
471+
if error_type not in ['std', 'sem']:
472+
raise ValueError("error_type must be 'std' or 'sem'")
473+
474+
# Prepare data
475+
marker_series, categories, palette, replicate_boundaries = _prepare_marker_profile_data(
476+
adata, marker_column, plot_nan, replicate_column
477+
)
478+
479+
# Create figure
480+
fig, ax = plt.subplots(figsize=(10, 6))
481+
482+
n_proteins = adata.n_vars
483+
484+
# Plot each category
485+
for category in categories:
486+
# Get samples in this category
487+
if pd.isna(category):
488+
mask = marker_series.isna()
489+
category_name = "NaN"
490+
color = "gray"
491+
else:
492+
mask = marker_series == category
493+
category_name = str(category)
494+
color = palette.get(category, "gray")
495+
496+
# Get data for this category (each row is a sample/fraction profile)
497+
category_data = adata[mask, :].X
498+
499+
if category_data.shape[0] == 0:
500+
continue
501+
502+
# Calculate mean and error
503+
mean_profile = np.mean(category_data, axis=0)
504+
if error_type == 'std':
505+
error = np.std(category_data, axis=0)
506+
else: # 'sem'
507+
error = np.std(category_data, axis=0) / np.sqrt(category_data.shape[0])
508+
509+
# Plot mean line with shaded error region
510+
x = np.arange(n_proteins)
511+
ax.plot(x, mean_profile, color=color, linewidth=2, label=category_name)
512+
ax.fill_between(
513+
x,
514+
mean_profile - error,
515+
mean_profile + error,
516+
color=color,
517+
alpha=0.2,
518+
)
519+
520+
# Add vertical dashed lines at replicate boundaries
521+
if replicate_boundaries:
522+
for boundary in replicate_boundaries:
523+
ax.axvline(boundary, color="gray", linestyle="--", linewidth=1, alpha=0.7)
524+
525+
ax.set_ylabel(ylabel)
526+
ax.set_xlim(-0.5, n_proteins - 0.5)
527+
ax.legend()
528+
529+
# Set x-tick labels if requested
530+
if xticklabels:
531+
ax.set_xticks(range(n_proteins))
532+
ax.set_xticklabels(adata.var_names, rotation=90, ha="center")
533+
534+
plt.tight_layout()
535+
536+
_utils.savefig_or_show("marker_profiles", show=show, save=save)
537+
if show:
538+
return None
539+
return ax

grassp/tests/test_plotting_integration.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,91 @@ def test_marker_profiles_split_smoke(self):
551551

552552
assert axs is not None
553553

554+
def test_marker_profiles_smoke(self):
555+
"""Verify marker_profiles executes without error."""
556+
# Create small fractionation-style dataset
557+
np.random.seed(42)
558+
n_samples = 12 # 2 replicates × 6 fractions
559+
n_proteins = 40
560+
561+
# Create sample/fraction metadata
562+
obs_data = {
563+
'Fraction': [f'F{i % 6 + 1}' for i in range(n_samples)],
564+
'Replicate': [f'R{i // 6 + 1}' for i in range(n_samples)],
565+
'Compartment': pd.Categorical(
566+
['Cytosol'] * 4 + ['Nucleus'] * 4 + ['Mitochondria'] * 4
567+
),
568+
}
569+
obs = pd.DataFrame(obs_data, index=[f'Sample{i}' for i in range(n_samples)])
570+
571+
# Create protein metadata
572+
var_data = {
573+
'Replicate': [f'R{i // (n_proteins // 2) + 1}' for i in range(n_proteins)],
574+
}
575+
var = pd.DataFrame(var_data, index=[f'P{str(i).zfill(5)}' for i in range(n_proteins)])
576+
577+
# Create intensity matrix with compartment-specific patterns
578+
X = np.random.lognormal(mean=5, sigma=1, size=(n_samples, n_proteins))
579+
580+
adata = AnnData(X=X, obs=obs, var=var)
581+
582+
# Test basic plot with std
583+
with warnings.catch_warnings():
584+
warnings.filterwarnings("ignore")
585+
ax = qc.marker_profiles(
586+
adata, marker_column='Compartment', error_type='std', show=False
587+
)
588+
589+
assert ax is not None
590+
591+
# Test with sem
592+
with warnings.catch_warnings():
593+
warnings.filterwarnings("ignore")
594+
ax = qc.marker_profiles(
595+
adata,
596+
marker_column='Compartment',
597+
error_type='sem',
598+
show=False,
599+
)
600+
601+
assert ax is not None
602+
603+
# Test with xticklabels
604+
with warnings.catch_warnings():
605+
warnings.filterwarnings("ignore")
606+
ax = qc.marker_profiles(
607+
adata,
608+
marker_column='Compartment',
609+
xticklabels=True,
610+
show=False,
611+
)
612+
613+
assert ax is not None
614+
615+
# Test with replicate_column
616+
with warnings.catch_warnings():
617+
warnings.filterwarnings("ignore")
618+
ax = qc.marker_profiles(
619+
adata,
620+
marker_column='Compartment',
621+
replicate_column='Replicate',
622+
show=False,
623+
)
624+
625+
assert ax is not None
626+
627+
# Test with custom ylabel
628+
with warnings.catch_warnings():
629+
warnings.filterwarnings("ignore")
630+
ax = qc.marker_profiles(
631+
adata,
632+
marker_column='Compartment',
633+
ylabel='Log2 Intensity',
634+
show=False,
635+
)
636+
637+
assert ax is not None
638+
554639

555640
# ==============================================================================
556641
# Ternary Plot Tests

0 commit comments

Comments
 (0)