From 069677844e62b83544c0aeac412a5a111daaac00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Levente=20Temesv=C3=A1ri-Nagy?= <147416790+leventetn@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:09:34 +0200 Subject: [PATCH 1/4] updated: proteoform_intensities, peptide_intensities with facet_by parameter Implemented in intensities.py Key results: - Added sample-level row/column faceting. - Preserved facet_by=None behavior. - Kept colors and y-axis consistent across facets. - Forwarded facet_by through proteoform_intensities. - Added explicit validation and ax=True incompatibility. --- HISTORY.md | 11 ++ proteopy/pl/intensities.py | 345 ++++++++++++++++++++++++++++++++++++- 2 files changed, 347 insertions(+), 9 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index 25ae251..473988a 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -9,6 +9,17 @@ and this project adheres to ### Added +- **Plotting** (`pr.pl`): `peptide_intensities()` and + `proteoform_intensities()` gained a `facet_by` parameter that splits + the samples (`.obs`) across a grid of subplots. It takes a length-2 + `[row, col]` sequence of `.obs` columns (a bare string is coerced to + `[str, None]`, and either element may be `None` to collapse that grid + axis). Each panel keeps the full `order_by` layout for its own sample + subset, and all panels share the y-axis and the peptide/proteoform + color mapping so they stay comparable. `facet_by` cannot be combined + with `ax`. With `facet_by`, `figsize` is the size of one panel and the + figure is scaled by the grid dimensions. + - **Preprocessing** (`pr.pp`): `summarize_peptides_by_neighbourhood_union()` collapses peptides that overlap in the protein sequence, keeping the most abundant member of each group. Peptide positions are resolved diff --git a/proteopy/pl/intensities.py b/proteopy/pl/intensities.py index 5b5c9d6..b5dc654 100644 --- a/proteopy/pl/intensities.py +++ b/proteopy/pl/intensities.py @@ -27,12 +27,251 @@ from proteopy.utils.functools import partial_with_docsig +def _facet_value_mask(series: pd.Series, value: Any) -> pd.Series: + if pd.isna(value): + return series.isna() + return series == value + + +def _facet_levels(obs: pd.DataFrame, key: str | None) -> list: + if key is None: + return [None] + return list(pd.unique(obs[key])) + + +def _add_peptide_intensity_legend( + ax: Axes, + panel_df: pd.DataFrame, + group_by: str | None, + color: str | None, +) -> None: + handles, labels = ax.get_legend_handles_labels() + if group_by: + hue_values = panel_df[group_by].dropna().unique().astype(str) + elif color: + hue_values = panel_df[color].dropna().unique().astype(str) + else: + hue_values = panel_df["var_index"].dropna().unique().astype(str) + handles_labels = [ + (handle, label) + for handle, label in zip(handles, labels) + if label in hue_values + ] + if handles_labels: + handles, labels = zip(*handles_labels) + legend_title = group_by if group_by else color if color else "Peptide" + ax.legend( + handles, + labels, + bbox_to_anchor=(1.01, 1), + loc="upper left", + title=legend_title, + ) + + +def _plot_peptide_intensity_facets( + adata: ad.AnnData, + sub_df: pd.DataFrame, + obs: pd.DataFrame, + facet_by: list[str | None], + order_by: str | None, + obs_index_ordered: list, + cats_ordered: list, + cat_index_map: dict, + group_by: str | None, + color: str | None, + palette_map: dict | None, + xlab_rotation: float, + order_by_label_rotation: float, + figsize: tuple[float, float], + prot_id: str, +) -> mpl.figure.Figure: + row_key, col_key = facet_by + row_levels = _facet_levels(adata.obs, row_key) + col_levels = _facet_levels(adata.obs, col_key) + nrows = len(row_levels) + ncols = len(col_levels) + fig, facet_axes = plt.subplots( + nrows=nrows, + ncols=ncols, + figsize=(figsize[0] * ncols, figsize[1] * nrows), + sharey=True, + squeeze=False, + ) + + finite_intensities = sub_df.loc[ + np.isfinite(sub_df["intensity"]), "intensity" + ] + y_limits = None + ymax = None + ypad_top = None + if not finite_intensities.empty: + ymin = finite_intensities.min() + ymax = finite_intensities.max() + intensity_range = ymax - ymin + if intensity_range == 0: + intensity_range = max(abs(ymax), 1.0) + ypad_top = intensity_range * 0.15 + ypad_bottom = intensity_range * 0.10 + y_limits = (ymin - ypad_bottom, ymax + ypad_top) + + for row_idx, row_value in enumerate(row_levels): + for col_idx, col_value in enumerate(col_levels): + facet_ax = facet_axes[row_idx, col_idx] + panel_obs = obs + title_parts = [] + + if row_key is not None: + panel_obs = panel_obs[ + _facet_value_mask(panel_obs[row_key], row_value) + ] + title_parts.append(f"{row_key}={row_value}") + if col_key is not None: + panel_obs = panel_obs[ + _facet_value_mask(panel_obs[col_key], col_value) + ] + title_parts.append(f"{col_key}={col_value}") + + panel_obs_ids = set(panel_obs["obs_index"]) + panel_df = sub_df[sub_df["obs_index"].isin(panel_obs_ids)].copy() + + if order_by: + panel_cat_index_map = { + cat: [ + obs_id + for obs_id in cat_index_map[cat] + if obs_id in panel_obs_ids + ] + for cat in cats_ordered + } + panel_cats_ordered = [ + cat for cat in cats_ordered if panel_cat_index_map[cat] + ] + panel_obs_ordered = [ + obs_id + for cat in panel_cats_ordered + for obs_id in panel_cat_index_map[cat] + ] + else: + panel_cat_index_map = {} + panel_cats_ordered = [] + panel_obs_ordered = [ + obs_id + for obs_id in obs_index_ordered + if obs_id in panel_obs_ids + ] + + panel_df["obs_index"] = pd.Categorical( + panel_df["obs_index"], + categories=panel_obs_ordered, + ordered=True, + ) + + if panel_df.empty: + facet_ax.text( + 0.5, + 0.5, + "No data for facet", + ha="center", + va="center", + transform=facet_ax.transAxes, + color="gray", + ) + facet_ax.set_xlim(0, 1) + facet_ax.set_xticks([]) + else: + lineplot_kwargs = dict( + data=panel_df, + x="obs_index", + y="intensity", + marker="o", + dashes=False, + legend="brief", + ax=facet_ax, + ) + if palette_map: + lineplot_kwargs["palette"] = palette_map + if order_by: + lineplot_kwargs["style"] = order_by + if group_by: + lineplot_kwargs["hue"] = group_by + elif color: + lineplot_kwargs.update( + hue=color, + units="var_index", + estimator=None, + errorbar=None, + ) + else: + lineplot_kwargs["hue"] = "var_index" + + sns.lineplot(**lineplot_kwargs) + _add_peptide_intensity_legend( + ax=facet_ax, + panel_df=panel_df, + group_by=group_by, + color=color, + ) + + obs_idxpos_map = { + obs_id: idx for idx, obs_id in enumerate(panel_obs_ordered) + } + if order_by: + for cat in panel_cats_ordered[:-1]: + last_obs = panel_cat_index_map[cat][-1] + facet_ax.axvline( + x=obs_idxpos_map[last_obs] + 0.5, + ymin=0.02, + ymax=0.95, + color="#D8D8D8", + linestyle="--", + ) + + if y_limits is not None: + for cat in panel_cats_ordered: + group_obs = panel_cat_index_map[cat] + start = obs_idxpos_map[group_obs[0]] + end = obs_idxpos_map[group_obs[-1]] + mid = (start + end) / 2 + rotation = order_by_label_rotation or 0 + horizontal_alignment = ( + "center" if rotation % 360 == 0 else "left" + ) + facet_ax.text( + x=mid, + y=ymax + ypad_top * 0.4, + s=cat, + ha=horizontal_alignment, + va="bottom", + fontsize=12, + fontweight="bold", + rotation=rotation, + rotation_mode="anchor", + ) + + if y_limits is not None: + facet_ax.set_ylim(*y_limits) + facet_ax.set_title(" | ".join(title_parts)) + facet_ax.set_xlabel("Sample") + facet_ax.set_ylabel("Intensity") + plt.setp( + facet_ax.get_xticklabels(), + rotation=xlab_rotation, + ha="right", + ) + + fig.suptitle(prot_id) + fig.tight_layout(rect=(0, 0, 1, 0.98)) + return fig + + def peptide_intensities( adata: ad.AnnData, protein_ids: str | Sequence[str] | None = None, order_by: str | None = None, order: Sequence[str] | None = None, groups: str | Sequence[str] | None = None, + facet_by: str | list[str | None] | None = None, color: str | None = None, group_by: str | None = None, log_transform: float | None = None, @@ -57,7 +296,8 @@ def peptide_intensities( protein_ids : str | Sequence[str] Show peptides mapping to this protein_id. order_by : str, optional - Column in ``adata.obs`` used to group and order observations on the x-axis. + Column in ``adata.obs`` used to group and order observations on the + x-axis. When ``None``, observations follow ``adata.obs_names``. order : Sequence[str], optional Explicit order of groups (when ``order_by`` is set) or observations @@ -66,10 +306,18 @@ def peptide_intensities( Restrict ``order_by`` to selected categorical levels (requires ``order_by``). The provided order determines the plotting order unless ``order`` is supplied. + facet_by : str | list[str | None], optional + Columns in ``adata.obs`` used to split samples across a subplot grid. + A string is coerced to ``[facet_by, None]``. A list must contain + exactly two elements: the row annotation followed by the column + annotation. Either element may be ``None`` to collapse that grid + axis. Within each facet, ``order_by`` groups and orders only the + samples in that facet. Cannot be combined with ``ax=True``. color : str, optional ``adata.var`` column used for per-peptide coloring. group_by : str, optional - ``adata.var`` column whose categories are aggregated into a single line. + ``adata.var`` column whose categories are aggregated into a single + line. Mutually exclusive with ``color``; each group is colored via ``color_scheme``. log_transform : float, optional @@ -78,7 +326,8 @@ def peptide_intensities( fill_na : float, optional Replace missing intensities before zero/log/z transformations when set. z_transform : bool, optional - Standardize each peptide across observations after optional log transform. + Standardize each peptide across observations after optional log + transform. Skips NA. show_zeros : bool, optional Display zero intensities when ``True``; otherwise zeros become ``NaN``. @@ -87,7 +336,8 @@ def peptide_intensities( order_by_label_rotation : float, optional Rotation angle for the group labels drawn above grouped sections. figsize : tuple[float, float], optional - Size of each generated figure passed to :func:`matplotlib.pyplot.subplots`. + Size of each panel. Faceted figures scale this size by the number of + subplot columns and rows. color_scheme : Any, optional Palette specification forwarded to :func:`proteopy.utils.matplotlib._resolve_color_scheme` for either the @@ -95,10 +345,11 @@ def peptide_intensities( show : bool, optional Display the generated figure(s) with :func:`matplotlib.pyplot.show`. save : str | os.PathLike, optional - Path for saving the figure(s). Multi-protein selections are written to a - PDF stack. + Path for saving the figure(s). Multi-protein selections are written to + a PDF stack. ax : bool, optional - When ``True``, return the underlying Axes objects instead of closing them. + When ``True``, return the underlying Axes objects instead of closing + them. Cannot be combined with ``facet_by``. Returns ------- @@ -129,6 +380,29 @@ def peptide_intensities( "`groups` can only be used when `order_by` is provided." ) + if isinstance(facet_by, str): + facet_by = [facet_by, None] + elif facet_by is not None: + if not isinstance(facet_by, list) or len(facet_by) != 2: + raise ValueError( + "`facet_by` must be a string or a list of exactly two " + "elements: [row_annotation, column_annotation]." + ) + + if facet_by is not None: + for facet_key in facet_by: + if facet_key is not None and not isinstance(facet_key, str): + raise TypeError( + "Each `facet_by` element must be a string or None." + ) + if facet_key is not None and facet_key not in adata.obs.columns: + raise KeyError(f"'{facet_key}' is not present in adata.obs") + if ax: + raise ValueError( + "`facet_by` cannot be combined with `ax=True`; faceted " + "plots create their own subplot grid." + ) + if groups is None: group_levels = None elif isinstance(groups, str): @@ -206,6 +480,13 @@ def peptide_intensities( palette_map = dict(zip(hue_labels, palette_values)) obs = adata.obs.reset_index(names="obs_index") + obs_cols = ["obs_index"] + if facet_by is not None: + obs_cols.extend( + facet_key + for facet_key in facet_by + if facet_key is not None and facet_key not in obs_cols + ) if order_by: if order_by not in obs.columns: @@ -214,7 +495,9 @@ def peptide_intensities( if not is_categorical_dtype(obs[order_by]): obs[order_by] = obs[order_by].astype("category") - obs = obs[["obs_index", order_by]] + if order_by not in obs_cols: + obs_cols.append(order_by) + obs = obs[obs_cols] if group_levels is not None: available_groups = set(obs[order_by].dropna().unique()) @@ -234,7 +517,7 @@ def peptide_intensities( if is_categorical_dtype(obs[order_by]): obs[order_by] = obs[order_by].cat.remove_unused_categories() else: - obs = obs[["obs_index"]] + obs = obs[obs_cols] if selected_vars: adata_subset = adata[:, selected_vars] @@ -357,6 +640,49 @@ def peptide_intensities( for prot_id in protein_ids: sub_df = df[df["protein_id"] == prot_id] + + if facet_by is not None: + allowed_obs_ids = set(obs["obs_index"]) + facet_sub_df = sub_df[sub_df["obs_index"].isin(allowed_obs_ids)] + if facet_sub_df.empty: + warnings.warn(f"No data found for protein: {prot_id}") + fig = _plot_peptide_intensity_facets( + adata=adata, + sub_df=facet_sub_df, + obs=obs, + facet_by=facet_by, + order_by=order_by, + obs_index_ordered=obs_index_ordered, + cats_ordered=cats_ordered, + cat_index_map=cat_index_map, + group_by=group_by, + color=color, + palette_map=palette_map, + xlab_rotation=xlab_rotation, + order_by_label_rotation=order_by_label_rotation, + figsize=figsize, + prot_id=prot_id, + ) + + if save: + if len(protein_ids) == 1: + fig.savefig(save, bbox_inches="tight", dpi=300) + else: + pdf_pages.savefig(fig, bbox_inches="tight") + if show: + plt.show() + plt.close(fig) + elif show: + plt.show() + plt.close(fig) + else: + print( + "Warning: Plot created but not displayed, saved, " + "or returned" + ) + plt.close(fig) + continue + fig, _ax = plt.subplots(figsize=figsize) if sub_df.empty: @@ -548,6 +874,7 @@ def peptide_intensities( proteoform_intensities = partial_with_docsig( peptide_intensities, color="proteoform_id", + docstr_header=docstr_header, ) From 25696edf7989c3972880fd791dc35210970aab40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Levente=20Temesv=C3=A1ri-Nagy?= <147416790+leventetn@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:39:43 +0200 Subject: [PATCH 2/4] updated: AGENTS.md sparsed matrix handling, layer and ax parameter conventions --- AGENTS.md | 161 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 102 insertions(+), 59 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 18710f4..ed854bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,13 +68,12 @@ check_proteodata(adata, *, layers=None) -> tuple[bool, str | None] # raises ValueError on failure ``` -If the function uses the AnnData.X matrix, always check if it is a -scipy.sparse matrix. As a general practice, the matrix will be made -non-sparse for its algorithm but if possible sparse operations on the -sparse matrix will be used to obtain the same result. If the function -modifies the AnnData.X matrix inplace or returns a transformed AnnData -matrix and the input was a sparse matrix, it is ensured that the output -AnnData.X matrix is also sparse. +If a function uses `AnnData.X` or a selected layer, always detect whether +that matrix is `scipy.sparse`. Sparse input is supported by warning the +user with `warnings.warn(..., UserWarning, stacklevel=2)`, converting the +selected matrix with `.toarray()`, and running the algorithm on a dense +array. Computed or transformed output remains dense; do not reject sparse +input and do not convert the output back to sparse. General argument guidelines: @@ -82,9 +81,6 @@ General argument guidelines: adata : AnnData Input AnnData with .X (obs x vars) and .var annotations. Default=None (do not include this line in docstrings) - layer : str | None - Optional key in `adata.layers`; when set, quantification uses that layer - Default=None (do not include this line in docstrings) zero_to_na : bool If True zeros in the AnnData X matrix will be replaced with np.nan prior to function execution. Default=None (do not include this line in docstrings) @@ -92,6 +88,12 @@ General argument guidelines: If True, NAs in the AnnData X matrix will be replaced with the argument. Default=None (do not include this line in docstrings) - selectively relevant arguments: + layer : str | None + Add this parameter only when the function explicitly needs or supports + reading from an `adata.layers` matrix. Functions normally operate on + `adata.X` and should not expose `layer` by default. When present, + `None` selects `adata.X` and a string selects that layer. + Default=None (do not include this line in docstrings) metadata_key : str When the function requires a metadata (.obs or .var) key by definition, this argument supplies the column. For example the function batch_correct would require the argument batch_key found in .obs. Replace metadata in metadata_key with the expected type of metadata. Default=Depends on the function and convention (do not include this line in docstrings) @@ -132,9 +134,10 @@ this number. If None, use the internal function defaults. If str/Path, save to that path. If False, do not save. Default=None (do not include this line in docstrings) ax : matplotlib.axes.Axes | None - Matplotlib Axes object to plot onto. If `None`, a new figure and axes are - created. Useful for embedding plots into subplot grids or further customization. - The function always returns the Axes object used for plotting. + Matplotlib Axes object to plot onto. If provided, draw on that exact + object without replacing it. If `None`, create a new figure and axes. + Always return the Axes object used, including when `show=True` or the + figure is saved. Default=None (do not include this line in docstrings) print_stats : bool If True, print the statistics underlying the plot as a pandas @@ -263,17 +266,22 @@ Use `is_proteodata()` when you need to detect whether the data conforms and at which level; it returns `(True, "peptide")`, `(True, "protein")`, or `(False, None)`. -#### 2) Handle sparse `.X` consistently -If a function uses `AnnData.X`: -- Detect sparsity via `scipy.sparse.issparse(adata.X)`. -- Prefer sparse-preserving operations when they can yield identical results. -- If an algorithm requires dense operations, temporarily convert to dense, but: - - If the input was sparse and the function modifies `AnnData.X` in-place or - returns a transformed `AnnData`, ensure the output remains sparse (same format - or CSR by default), unless there is a documented reason not to. +#### 2) Handle sparse matrices consistently +If a function uses `AnnData.X` or a selected layer: +- Detect sparsity via `scipy.sparse.issparse(Xsrc)`. +- If sparse, emit `UserWarning` with `stacklevel=2` explaining that the + selected matrix is being densified. +- Convert with `Xsrc.toarray()` and perform the algorithm on a dense matrix. +- Keep computed or transformed output dense. Do not reject sparse input and + do not convert output back to a sparse format. +- Test that sparse input warns and produces the same dense values as the + equivalent dense input. Skeleton pattern: ```python +import warnings + +import numpy as np from scipy import sparse def example_fn(adata, *, inplace=True, **kwargs): @@ -281,28 +289,26 @@ def example_fn(adata, *, inplace=True, **kwargs): from proteopy.utils.anndata import check_proteodata check_proteodata(adata) - X = adata.X - was_sparse = sparse.issparse(X) - - # Use sparse ops when possible; otherwise densify temporarily - if was_sparse: - # try sparse-safe path - pass + Xsrc = adata.X + if sparse.issparse(Xsrc): + warnings.warn( + "Sparse input is being densified for computation.", + UserWarning, + stacklevel=2, + ) + X = Xsrc.toarray() else: - pass + X = np.asarray(Xsrc) # ... compute, optionally producing X_new ... if inplace: - if was_sparse and not sparse.issparse(adata.X): - adata.X = sparse.csr_matrix(adata.X) + adata.X = np.asarray(X_new) check_proteodata(adata) # validate before returning return None else: adata_out = adata.copy() - # assign X_new to adata_out.X - if was_sparse and not sparse.issparse(adata_out.X): - adata_out.X = sparse.csr_matrix(adata_out.X) + adata_out.X = np.asarray(X_new) check_proteodata(adata_out) # validate before returning return adata_out ``` @@ -339,11 +345,14 @@ their data appropriately. |-----------|--------------| | `adata` | AnnData object with `.X`, `.obs`, and `.var` annotations | | `group_by` | Column in `adata.var` or `adata.obs` used for grouping | -| `layer` | Optional key in `adata.layers` specifying quantification data | | `zero_to_na` | Convert zeros in `.X` to `np.nan` | | `fill_na` | Replace missing values in `.X` with a specified constant | | `verbose` | Print status messages about input data and output destinations (default: `False`) | +Functions operate on `adata.X` by default. Add a `layer` parameter only when +the function explicitly needs or supports choosing an alternate matrix from +`adata.layers`; do not include it as a routine parameter otherwise. + ### Additional Argument Conventions #### Preprocessing and Tool Modules (`pp`, `tl`) @@ -364,7 +373,31 @@ To ensure consistent plotting behavior across `pl.*` modules, adhere to the foll Save the figure: str/Path for a specific path, None to skip saving (default=None). - `ax: matplotlib.axes.Axes | None` - Matplotlib Axes object to plot onto. If `None`, a new figure and axes are created. The function always returns the Axes object used for plotting (default=None). + Matplotlib Axes object to plot onto. If supplied, plot on that exact object; + do not create, replace, or clear it. If `None`, create a new figure and axes. + Always return the Axes object used, so `returned_ax is ax` when one was + supplied. Showing or saving the figure does not change the return value + (default=None). + +Required implementation pattern: +```python +def example_plot(..., ax=None, show=True, save=None): + if ax is None: + _, ax = plt.subplots() + + ax.plot(x, y) + + if save is not None: + ax.figure.savefig(save) + if show: + plt.show() + return ax + + +fig, supplied_ax = plt.subplots() +returned_ax = example_plot(..., ax=supplied_ax, show=False) +assert returned_ax is supplied_ax +``` - `print_stats: bool` If True, print the statistics underlying the plot as a pandas @@ -755,17 +788,22 @@ Use `is_proteodata()` when you need to detect whether the data conforms and at which level; it returns `(True, "peptide")`, `(True, "protein")`, or `(False, None)`. -#### 2) Handle sparse `.X` consistently -If a function uses `AnnData.X`: -- Detect sparsity via `scipy.sparse.issparse(adata.X)`. -- Prefer sparse-preserving operations when they can yield identical results. -- If an algorithm requires dense operations, temporarily convert to dense, but: - - If the input was sparse and the function modifies `AnnData.X` in-place or - returns a transformed `AnnData`, ensure the output remains sparse (same format - or CSR by default), unless there is a documented reason not to. +#### 2) Handle sparse matrices consistently +If a function uses `AnnData.X` or a selected layer: +- Detect sparsity via `scipy.sparse.issparse(Xsrc)`. +- If sparse, emit `UserWarning` with `stacklevel=2` explaining that the + selected matrix is being densified. +- Convert with `Xsrc.toarray()` and perform the algorithm on a dense matrix. +- Keep computed or transformed output dense. Do not reject sparse input and + do not convert output back to a sparse format. +- Test that sparse input warns and produces the same dense values as the + equivalent dense input. Skeleton pattern: ```python +import warnings + +import numpy as np from scipy import sparse def example_fn(adata, *, inplace=True, **kwargs): @@ -773,28 +811,26 @@ def example_fn(adata, *, inplace=True, **kwargs): from proteopy.utils.anndata import check_proteodata check_proteodata(adata) - X = adata.X - was_sparse = sparse.issparse(X) - - # Use sparse ops when possible; otherwise densify temporarily - if was_sparse: - # try sparse-safe path - pass + Xsrc = adata.X + if sparse.issparse(Xsrc): + warnings.warn( + "Sparse input is being densified for computation.", + UserWarning, + stacklevel=2, + ) + X = Xsrc.toarray() else: - pass + X = np.asarray(Xsrc) # ... compute, optionally producing X_new ... if inplace: - if was_sparse and not sparse.issparse(adata.X): - adata.X = sparse.csr_matrix(adata.X) + adata.X = np.asarray(X_new) check_proteodata(adata) # validate before returning return None else: adata_out = adata.copy() - # assign X_new to adata_out.X - if was_sparse and not sparse.issparse(adata_out.X): - adata_out.X = sparse.csr_matrix(adata_out.X) + adata_out.X = np.asarray(X_new) check_proteodata(adata_out) # validate before returning return adata_out ``` @@ -808,11 +844,14 @@ def example_fn(adata, *, inplace=True, **kwargs): |-----------|--------------| | `adata` | AnnData object with `.X`, `.obs`, and `.var` annotations | | `group_by` | Column in `adata.var` or `adata.obs` used for grouping | -| `layer` | Optional key in `adata.layers` specifying quantification data | | `zero_to_na` | Convert zeros in `.X` to `np.nan` | | `fill_na` | Replace missing values in `.X` with a specified constant | | `verbose` | Print status messages about input data and output destinations (default: `False`) | +Functions operate on `adata.X` by default. Add a `layer` parameter only when +the function explicitly needs or supports choosing an alternate matrix from +`adata.layers`; do not include it as a routine parameter otherwise. + ### Additional Argument Conventions #### Cross-Module Arguments (`pp`, `tl`, `pl`, `ann`, and others) @@ -836,7 +875,11 @@ To ensure consistent plotting behavior across `pl.*` modules, adhere to the foll Save the figure: str/Path for a specific path, None to skip saving (default=None). - `ax: matplotlib.axes.Axes | None` - Matplotlib Axes object to plot onto. If `None`, a new figure and axes are created. The function always returns the Axes object used for plotting (default=None). + Matplotlib Axes object to plot onto. If supplied, plot on that exact object; + do not create, replace, or clear it. If `None`, create a new figure and axes. + Always return the Axes object used, so `returned_ax is ax` when one was + supplied. Showing or saving the figure does not change the return value + (default=None). - `print_stats: bool` If True, print the statistics underlying the plot as a pandas From 41b0c7bda1602b8892454986d125a0298c63deb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Levente=20Temesv=C3=A1ri-Nagy?= <147416790+leventetn@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:51:56 +0200 Subject: [PATCH 3/4] updated: peptide_intensities ax parameter Updated intensities.py New contract: ax: Axes | None = None ) -> Axes | list[Axes] Behavior: - Supplied ax is used exactly and returned by identity. - ax=None creates and returns new axes. - Faceted plots return their subplot axes as a list. - facet_by and supplied ax raise ValueError. - Multiple protein_ids with one supplied ax raise ValueError. - Showing or saving does not change the return value. --- proteopy/pl/intensities.py | 83 +++++++++++++++++--------------------- 1 file changed, 37 insertions(+), 46 deletions(-) diff --git a/proteopy/pl/intensities.py b/proteopy/pl/intensities.py index b5dc654..a19868a 100644 --- a/proteopy/pl/intensities.py +++ b/proteopy/pl/intensities.py @@ -283,9 +283,9 @@ def peptide_intensities( figsize: tuple[float, float] = (15, 6), show: bool = True, save: str | os.PathLike[str] | None = None, - ax: bool = False, + ax: Axes | None = None, color_scheme: Any = None, -) -> Axes | list[Axes] | None: +) -> Axes | list[Axes]: """ Plot peptide intensities across samples for the requested proteins. @@ -312,7 +312,7 @@ def peptide_intensities( exactly two elements: the row annotation followed by the column annotation. Either element may be ``None`` to collapse that grid axis. Within each facet, ``order_by`` groups and orders only the - samples in that facet. Cannot be combined with ``ax=True``. + samples in that facet. Cannot be combined with ``ax``. color : str, optional ``adata.var`` column used for per-peptide coloring. group_by : str, optional @@ -347,14 +347,15 @@ def peptide_intensities( save : str | os.PathLike, optional Path for saving the figure(s). Multi-protein selections are written to a PDF stack. - ax : bool, optional - When ``True``, return the underlying Axes objects instead of closing - them. Cannot be combined with ``facet_by``. + ax : matplotlib.axes.Axes, optional + Axes object to plot onto. If ``None``, create a new figure and axes. + Cannot be combined with ``facet_by`` or multiple ``protein_ids``. Returns ------- - Axes | list[Axes] | None - Axes handle(s) when ``ax`` is ``True``; otherwise ``None``. + Axes | list[Axes] + The Axes object used for one plot, or a list of Axes objects when + multiple plots are created. """ # Check input @@ -397,12 +398,20 @@ def peptide_intensities( ) if facet_key is not None and facet_key not in adata.obs.columns: raise KeyError(f"'{facet_key}' is not present in adata.obs") - if ax: + if ax is not None: raise ValueError( - "`facet_by` cannot be combined with `ax=True`; faceted " + "`facet_by` cannot be combined with `ax`; faceted " "plots create their own subplot grid." ) + if ax is not None and not isinstance(ax, Axes): + raise TypeError("`ax` must be a matplotlib.axes.Axes or None.") + if ax is not None and len(protein_ids) > 1: + raise ValueError( + "`ax` cannot be combined with multiple `protein_ids`; " + "provide one protein or let the function create the axes." + ) + if groups is None: group_levels = None elif isinstance(groups, str): @@ -663,6 +672,7 @@ def peptide_intensities( figsize=figsize, prot_id=prot_id, ) + axes.extend(fig.axes) if save: if len(protein_ids) == 1: @@ -671,19 +681,16 @@ def peptide_intensities( pdf_pages.savefig(fig, bbox_inches="tight") if show: plt.show() - plt.close(fig) elif show: plt.show() - plt.close(fig) - else: - print( - "Warning: Plot created but not displayed, saved, " - "or returned" - ) - plt.close(fig) continue - fig, _ax = plt.subplots(figsize=figsize) + created_fig = ax is None + if created_fig: + fig, _ax = plt.subplots(figsize=figsize) + else: + _ax = ax + fig = _ax.figure if sub_df.empty: warnings.warn(f"No data found for protein: {prot_id}") @@ -824,47 +831,31 @@ def peptide_intensities( rotation_mode="anchor", ) - plt.xticks(rotation=xlab_rotation, ha="right") + plt.setp( + _ax.get_xticklabels(), + rotation=xlab_rotation, + ha="right", + ) _ax.set_title(prot_id) _ax.set_xlabel("Sample") _ax.set_ylabel("Intensity") - plt.tight_layout() - - if ax: - axes.append(_ax) - - if show: - plt.show() - - elif save: + if created_fig: + fig.tight_layout() + axes.append(_ax) + if save: if len(protein_ids) == 1: fig.savefig(save, bbox_inches="tight", dpi=300) - else: pdf_pages.savefig(fig, bbox_inches="tight") - - if show: - plt.show() - - plt.close(fig) - - elif show: + if show: plt.show() - plt.close(fig) - - else: - print( - "Warning: Plot created but not displayed, saved, or returned" - ) - plt.close(fig) if save and len(protein_ids) > 1: pdf_pages.close() - if ax: - return axes[0] if len(axes) == 1 else axes + return axes[0] if len(axes) == 1 else axes docstr_header = ( From c1a3c8b84745095a0979d8f4bd7addf39fef8c05 Mon Sep 17 00:00:00 2001 From: Ian Dirk Fichtner Date: Mon, 7 Sep 2026 00:40:36 +0200 Subject: [PATCH 4/4] update(HISTORY.md): shorter descriptions --- HISTORY.md | 192 +++++++++++++++++++++++++++++------------------------ 1 file changed, 104 insertions(+), 88 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index 473988a..99e4b70 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,7 +1,8 @@ # Changelog All notable changes to ProteoPy will be documented in this file. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +The format is based on +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). @@ -9,104 +10,105 @@ and this project adheres to ### Added -- **Plotting** (`pr.pl`): `peptide_intensities()` and - `proteoform_intensities()` gained a `facet_by` parameter that splits - the samples (`.obs`) across a grid of subplots. It takes a length-2 - `[row, col]` sequence of `.obs` columns (a bare string is coerced to - `[str, None]`, and either element may be `None` to collapse that grid - axis). Each panel keeps the full `order_by` layout for its own sample - subset, and all panels share the y-axis and the peptide/proteoform - color mapping so they stay comparable. `facet_by` cannot be combined - with `ax`. With `facet_by`, `figsize` is the size of one panel and the - figure is scaled by the grid dimensions. - -- **Preprocessing** (`pr.pp`): `summarize_peptides_by_neighbourhood_union()` - collapses peptides that overlap in the protein sequence, keeping the - most abundant member of each group. Peptide positions are resolved - from a FASTA in the same call. A reimplementation of CCprofiler's - `summarizeAlternativePeptideSequences(topN = 1)`. - - groups by positional overlap rather than substring containment, so - it sees peptide pairs that overlap without either containing the - other, and needs no separate modification-summarisation step - - selects the most abundant member instead of aggregating; `top_n` - sums the leading members instead, with `keep_less` controlling - undersized groups - - missing values are deprioritised rather than removed: an - incomplete peptide sorts last and loses to any complete - competitor, but survives if its group has no complete member - - equal totals are resolved by `tie_break_key`, so the result does - not depend on input row order; the default sorts non-letters after - letters, favouring the unmodified form of an identifier - - `on_unknown_protein` and `on_unlocated_peptide` decide whether an - unresolvable position raises, skips the peptide, or leaves the - position undefined - - `.var` is reduced to the peptide-level proteodata columns plus the - function's own output, since the surviving row's annotations - describe one member rather than the group; `keep_var_cols` carries - chosen columns through, aggregated across the group +**Plotting** (`pr.pl`) + +- `peptide_intensities()`, `proteoform_intensities()`: new `facet_by` + parameter splitting the samples (`.obs`) across a grid of subplots + +**Preprocessing** (`pr.pp`) + +- `summarize_peptides_by_neighbourhood_union()`: collapses peptides + that overlap in the protein sequence, keeping the most abundant + member of each group. Reimplements CCprofiler's + `summarizeAlternativePeptideSequences(topN = 1)` ### Fixed -- **Datasets** (`pr.datasets`) and **Download** (`pr.download`): - `williams_2018()` no longer conflates measured zeros with missing +**Datasets** (`pr.datasets`) and **Download** (`pr.download`) + +- `williams_2018()` no longer conflates measured zeros with missing values. Three defects, each masked by another: - zeros in `.X` were coerced to `np.nan`, discarding 13,547 genuine measurements - charge-state summation used pandas' default `min_count=0`, so a - group with no measurements at all summed to `0.0`, inventing 3,324 + group with no measurements summed to `0.0`, inventing 3,324 - the same summation skipped `NaN` inside a *partially* measured group, reporting a partial total as complete for 260 cells - - **this changes `.X`**, and therefore the output of - `pr.download.williams_2018()`. Verified against the PRIDE deposit - used by the original publication: the two are now bit-identical - with an identical missingness pattern. +- **This changes `.X`**, and therefore the output of + `pr.download.williams_2018()`. Verified against the PRIDE deposit + used by the original publication: the two are now bit-identical, + with an identical missingness pattern +- `williams_2018()`: new `zero_to_na` parameter (default `False`), for + consistency with sibling functions; mutually exclusive with + `fill_na`. Governs zero semantics only, and does not restore the + summation defects above ### Changed -- **Datasets** (`pr.datasets`) and **Download** (`pr.download`): - `williams_2018()` gained a `zero_to_na` parameter (default `False`), - for consistency with sibling functions. It is mutually exclusive - with `fill_na`. Note it governs zero semantics only and does not - restore the summation defects above. -- **Preprocessing** (`pr.pp`): `normalize_median()` +**Preprocessing** (`pr.pp`) + +- `normalize_median()`: - now defaults to `log_space=True` - - renamed the `batch_id` parameter to `group_by` - - renamed the `zeros_to_na` parameter to `zero_to_na`, for - consistency with sibling functions + - `batch_id` parameter renamed to `group_by` + - `zeros_to_na` parameter renamed to `zero_to_na`, for consistency + with sibling functions - no longer accepts sparse `.X` input ## [0.1.1] - 2025-03-24 ### Added -- **Preprocessing** (`pr.pp`): `summarize_modifications()` for - modification summarization -- **Analysis** (`pr.tl`): ANOVA support in `differential_abundance()` -- **Visualization** (`pr.pl`): `binary_heatmap()`, `box()`, - `volcano()`, `peptides_on_sequence()`, - `peptides_on_prot_sequence()`; `print_stats` parameter across - multiple plot functions -- **Datasets** (`pr.datasets`): `williams_2018()` and - `karayel_2020()` download functions -- **Utilities** (`pr.utils`): Public API with `is_proteodata()`, - `check_proteodata()`, `is_log_transformed()` -- **Documentation**: Sphinx documentation site; proteoform inference - and protein-level analysis tutorials +**Preprocessing** (`pr.pp`) + +- `summarize_modifications()` for modification summarization + +**Analysis** (`pr.tl`) + +- ANOVA support in `differential_abundance()` + +**Plotting** (`pr.pl`) + +- `binary_heatmap()`, `box()`, `volcano()`, + `peptides_on_sequence()`, `peptides_on_prot_sequence()` +- `print_stats` parameter across multiple plot functions + +**Datasets** (`pr.datasets`) + +- `williams_2018()` and `karayel_2020()` download functions + +**Utilities** (`pr.utils`) + +- public API: `is_proteodata()`, `check_proteodata()`, + `is_log_transformed()` + +**Documentation** (`docs/`) + +- Sphinx documentation site +- proteoform inference and protein-level analysis tutorials ### Changed -- **Reader** (`pr.read`): `diann()` now supports version >=1.9.1 - with automatic version dispatch -- **Preprocessing** (`pr.pp`): `impute_downshift()` now supports - `group_by`; `normalize_median()` gains `method` parameter; - `remove_contaminants()` defaults to `inplace=True` -- **Validation**: `is_proteodata()` now checks for NaN in ID - columns, infinite values in `.X`/layers, and obs/var index sync +**Reader** (`pr.read`) + +- `diann()` supports version >=1.9.1, with automatic version dispatch + +**Preprocessing** (`pr.pp`) + +- `impute_downshift()`: now supports `group_by` +- `normalize_median()`: new `method` parameter +- `remove_contaminants()`: defaults to `inplace=True` + +**Validation** (`pr.utils`) + +- `is_proteodata()` now checks for NaN in ID columns, infinite values + in `.X`/layers, and obs/var index sync ### Fixed -- `volcano_plot` type incompatibility and label display -- `n_cat1_per_cat2_hist` minimum bin width +**Plotting** (`pr.pl`) + +- `volcano_plot()`: type incompatibility and label display +- `n_cat1_per_cat2_hist()`: minimum bin width ## [0.1.0] - 2025-01-29 @@ -114,18 +116,32 @@ Initial release of ProteoPy. ### Added -- **Data import** (`pr.read`): Support for DIA-NN and generic - long-format tables -- **Annotation** (`pr.ann`): Functions to annotate samples (`.obs`) and - variables (`.var`) -- **Quality control** (`pr.pp`): Completeness filtering, CV calculation, +**Reader** (`pr.read`) + +- DIA-NN and generic long-format tables + +**Annotation** (`pr.ann`) + +- annotation of samples (`.obs`) and variables (`.var`) + +**Preprocessing** (`pr.pp`) + +- quality control: completeness filtering, CV calculation, contaminant removal -- **Preprocessing** (`pr.pp`): Median normalization, downshift imputation -- **Differential abundance** (`pr.tl`): t-test, Welch's test, ANOVA with - multiple testing correction -- **Proteoform inference** (`pr.tl`): COPF algorithm reimplementation for - detecting functional proteoform groups -- **Visualization** (`pr.pl`): Volcano plots, abundance rank plots, intensity - distributions, CV plots, correlation matrices, hierarchical clustering - profiles -- **Datasets** (`pr.datasets`): Built-in example datasets (Karayel 2020) +- median normalization, downshift imputation + +**Analysis** (`pr.tl`) + +- differential abundance: t-test, Welch's test, ANOVA with multiple + testing correction +- proteoform inference: COPF algorithm reimplementation for detecting + functional proteoform groups + +**Plotting** (`pr.pl`) + +- volcano, abundance rank, intensity distribution, CV, correlation + matrix and hierarchical clustering profile plots + +**Datasets** (`pr.datasets`) + +- built-in example datasets (Karayel 2020)