Source code for proteobench.plotting.plot_generator_entrapment

from typing import Any, Dict, Tuple

import numpy as np
import pandas as pd
import plotly.graph_objects as go

from proteobench.plotting.plot_generator_base import PlotGeneratorBase
from proteobench.score.entrapmentscores import EntrapmentScores


[docs] class EntrapmentPlotGenerator(PlotGeneratorBase): """ Plot generator for Entrapment modules. """
[docs] def generate_in_depth_plots( self, performance_data: pd.DataFrame, parse_settings: Any, **kwargs ) -> Dict[str, go.Figure]: """ Generate standard Entrapment plots. Parameters ---------- performance_data : pd.DataFrame The performance data to plot parse_settings : ParseSettings The parse settings for the module recalculate : bool Whether to recalculate or use cached plots **kwargs : dict Additional module-specific parameters Returns ------- Dict[str, go.Figure] Dictionary mapping plot names to plotly figures """ plots = {} # Generate QQ plot — use pre-computed curve if provided, otherwise derive it # from the intermediate DataFrame (e.g. for public datasets loaded from storage). plots["qq"] = self._plot_qq_plot(performance_data, fdp_curve=kwargs.get("fdp_curve")) return plots
[docs] def get_in_depth_plot_layout(self) -> list: """ Define layout for Entrapment plots. Returns ------- list List of in-depth plot configurations defining how plots should be displayed """ return [ { "plots": ["qq"], "columns": 1, "titles": { "qq": "FDP vs Q-value Threshold", }, }, ]
[docs] def get_in_depth_plot_descriptions(self) -> Dict[str, str]: """ Get descriptions for each plot. Returns ------- Dict[str, str] Dictionary mapping plot names to their descriptions """ return { "qq": ( "False discovery proportion (FDP) as a function of Q-value threshold. " "The grey dashed diagonal marks FDP = FDR (perfect calibration). " "Points below the diagonal indicate conservative FDR control; " "points above indicate that the empirical FDP exceeds the declared threshold. " "The shaded band spans the FDP uncertainty interval (lower bound to combined upper bound). " "The right axis shows the number of identified features at each threshold." ), }
def _plot_qq_plot(self, performance_data: pd.DataFrame, fdp_curve: dict = None) -> go.Figure: """ Plot FDP (lower bound, combined, paired) against Q-value threshold. Each FDP estimator is shown as a line. The grey dashed diagonal marks perfect calibration (FDP = declared FDR). The shaded band spans the uncertainty interval between the lower and combined upper FDP bounds. A secondary right-hand axis shows the number of identified features at each threshold. Parameters ---------- performance_data : pd.DataFrame Intermediate DataFrame produced by ``EntrapmentScores.generate_intermediate``. Must contain at least ``Q-Value``, ``Peptide``, and ``Target or Entrapment`` columns. fdp_curve : dict, optional Pre-computed FDP curve from ``EntrapmentScores.calculate_fdp_at_fdr_thresholds``. When provided the intermediate DataFrame is not re-processed. Pass this from the already-computed datapoint to avoid redundant computation. Returns ------- go.Figure Plotly figure with the FDP calibration curve. """ if fdp_curve is None: try: performance_data = EntrapmentScores.validate_entrapment_coverage(performance_data) fdp_curve = EntrapmentScores.calculate_fdp_at_fdr_thresholds(performance_data) except Exception as exc: fig = go.Figure() fig.update_layout( xaxis_title="Q-value threshold", yaxis_title="FDP", template="plotly_white", height=500, annotations=[ dict( text=f"Could not compute FDP curve: {exc}", x=0.5, y=0.5, xref="paper", yref="paper", showarrow=False, ) ], ) return fig if not fdp_curve: fig = go.Figure() fig.update_layout( template="plotly_white", height=500, annotations=[ dict( text="No FDP curve data — intermediate DataFrame may be empty.", x=0.5, y=0.5, xref="paper", yref="paper", showarrow=False, ) ], ) return fig thresholds = sorted(fdp_curve.keys()) lower = [fdp_curve[t]["lower_bound_FDP"] for t in thresholds] combined = [fdp_curve[t]["combined_FDP"] for t in thresholds] paired = [fdp_curve[t]["paired_FDP"] for t in thresholds] nr_features = [fdp_curve[t]["nr_id_features"] for t in thresholds] hover_lower = [ f"Q-value ≤ {t:.5f}<br>Lower bound FDP: {l:.4f}<br>Nr features: {n}" for t, l, n in zip(thresholds, lower, nr_features) ] hover_combined = [ f"Q-value ≤ {t:.5f}<br>Combined FDP: {c:.4f}<br>Nr features: {n}" for t, c, n in zip(thresholds, combined, nr_features) ] hover_paired = [ f"Q-value ≤ {t:.5f}<br>Paired FDP: {p:.4f}<br>Nr features: {n}" for t, p, n in zip(thresholds, paired, nr_features) ] hover_nr = [f"Q-value ≤ {t:.5f}<br>Nr features: {n}" for t, n in zip(thresholds, nr_features)] fig = go.Figure() # Shaded uncertainty band: lower bound → combined upper bound fig.add_trace( go.Scatter( x=thresholds + thresholds[::-1], y=combined + lower[::-1], fill="toself", fillcolor="rgba(120,120,120,0.12)", line=dict(color="rgba(0,0,0,0)"), name="FDP uncertainty band", hoverinfo="skip", showlegend=True, ) ) fig.add_trace( go.Scatter( x=thresholds, y=lower, mode="lines+markers", name="Lower bound FDP", line=dict(color="#2ecc71", width=2), marker=dict(size=7), hovertext=hover_lower, hoverinfo="text", ) ) fig.add_trace( go.Scatter( x=thresholds, y=combined, mode="lines+markers", name="Combined FDP (upper)", line=dict(color="#e74c3c", width=2), marker=dict(size=7), hovertext=hover_combined, hoverinfo="text", ) ) fig.add_trace( go.Scatter( x=thresholds, y=paired, mode="lines+markers", name="Paired FDP (upper)", line=dict(color="#f39c12", width=2, dash="dot"), marker=dict(size=7, symbol="diamond"), hovertext=hover_paired, hoverinfo="text", ) ) # Identity reference: FDP = declared FDR x_max = max(thresholds) fig.add_trace( go.Scatter( x=[0, x_max], y=[0, x_max], mode="lines", name="FDP = FDR (identity)", line=dict(color="gray", width=1.5, dash="dash"), hoverinfo="skip", ) ) # Nr. identified features on secondary right axis fig.add_trace( go.Scatter( x=thresholds, y=nr_features, mode="lines", name="Nr. identified features", line=dict(color="rgba(60,60,180,0.45)", width=1.5, dash="longdash"), yaxis="y2", hovertext=hover_nr, hoverinfo="text", ) ) fig.update_layout( xaxis=dict( title="Q-value threshold (declared FDR)", gridcolor="lightgray", linecolor="black", ), yaxis=dict( title="False Discovery Proportion (FDP)", gridcolor="lightgray", linecolor="black", rangemode="tozero", ), yaxis2=dict( title="Nr. identified features", overlaying="y", side="right", showgrid=False, linecolor="rgba(60,60,180,0.45)", rangemode="tozero", ), template="plotly_white", height=520, margin=dict(l=80, r=110, t=60, b=100), legend=dict(orientation="h", y=-0.28), ) return fig
[docs] def plot_fdp_ratio( self, result_df: pd.DataFrame, metric: str = "Upper FDP bound - Paired method", colorblind_mode: bool = False, software_markers: Dict[str, str] = { "MaxQuant": "circle", "AlphaPept": "square", "ProlineStudio": "diamond", "MSAngel": "cross", "FragPipe": "x", "i2MassChroQ": "triangle-up", "Sage": "triangle-down", "WOMBAT": "pentagon", "DIA-NN": "star", "AlphaDIA": "star-triangle-up", "Custom": "star-square", "Spectronaut": "diamond-tall", "FragPipe (DIA-NN quant)": "circle-x", "MSAID": "square-cross", "MetaMorpheus": "asterisk", "Proteome Discoverer": "hash", "PEAKS": "diamond-wide", "quantms": "hexagram", }, **kwargs, ) -> go.Figure: """ Plot the ratio of empirical FDP to reported FDR per workflow, grouped by software tool. Each marker represents one workflow submission. The y-axis shows how much the empirical false discovery proportion (FDP) deviates from the FDR threshold declared by the user. A ratio of 1 means FDP equals the claimed FDR; below 1 is better (FDP is lower than claimed). Markers are coloured by validity category (valid / inconclusive / invalid) recomputed against each workflow's own ``reported_fdr_parsed_from_input`` value. Parameters ---------- result_df : pd.DataFrame DataFrame containing all datapoints (one row per submitted workflow). Must include columns ``software_name``, ``reported_fdr_parsed_from_input``, ``lower_bound_FDP``, and the FDP column resolved from ``metric``. metric : str, optional Which FDP bound to place on the y-axis. One of ``"Lower FDP bound"``, ``"Upper FDP bound - Combined method"``, ``"Upper FDP bound - Paired method"``. colorblind_mode : bool, optional If ``True``, use distinct marker shapes per software tool in addition to category colours. software_markers : Dict[str, str] Mapping of software names to Plotly marker symbol strings. **kwargs : dict Ignored; accepted for call-site compatibility. Returns ------- go.Figure Plotly figure with the FDP-ratio strip plot. """ metric_col_name, plot_title = self._resolve_metric_column(metric) plot_df = result_df.copy() plot_df[metric_col_name] = pd.to_numeric(plot_df[metric_col_name], errors="coerce") plot_df["reported_fdr_parsed_from_input"] = pd.to_numeric( plot_df["reported_fdr_parsed_from_input"], errors="coerce" ) if "lower_bound_FDP" in plot_df.columns: plot_df["lower_bound_FDP"] = pd.to_numeric(plot_df["lower_bound_FDP"], errors="coerce") else: plot_df["lower_bound_FDP"] = np.nan # Use 0.01 as a fallback when reported_fdr_parsed_from_input is missing or zero so new # uploads are always rendered even when the user did not fill in the FDR field. plot_df["fdr_missing"] = plot_df["reported_fdr_parsed_from_input"].isna() | ( plot_df["reported_fdr_parsed_from_input"] == 0 ) fdr_effective = plot_df["reported_fdr_parsed_from_input"].copy() fdr_effective[plot_df["fdr_missing"]] = 0.01 plot_df["fdp_ratio"] = plot_df[metric_col_name] / fdr_effective def _categorise_row(row): lower = row.get("lower_bound_FDP", np.nan) upper = row.get(metric_col_name, np.nan) fdr = row.get("reported_fdr_parsed_from_input", np.nan) if any(pd.isna(v) for v in (lower, upper)) or pd.isna(fdr) or fdr == 0: # Re-categorise with the fallback FDR so points are still classified fdr = 0.01 if pd.isna(lower) or pd.isna(upper): return "inconclusive" if upper <= fdr: return "valid" elif lower > fdr: return "invalid" return "inconclusive" plot_df["category"] = plot_df.apply(_categorise_row, axis=1) category_colors = {"valid": "#2ecc71", "inconclusive": "#f39c12", "invalid": "#e74c3c"} if "old_new" in plot_df.columns: is_new = (plot_df["old_new"] == "new").tolist() else: is_new = [False] * len(plot_df) hover_texts = [] for row_is_new, (_, row) in zip(is_new, plot_df.iterrows()): ratio_str = f"{row['fdp_ratio']:.3f}" if pd.notna(row.get("fdp_ratio")) else "N/A" fdp_str = f"{row.get(metric_col_name, ''):.4f}" if pd.notna(row.get(metric_col_name)) else "N/A" fdr_display = row.get("reported_fdr_parsed_from_input", np.nan) fdr_label = f"{fdr_display}" if pd.notna(fdr_display) and fdr_display != 0 else "not set (0.01 used)" hover_text = ( f"ProteoBench ID: {row.get('id', '')}<br>" f"Software: {row.get('software_name', '')} {row.get('software_version', '')}<br>" f"{plot_title}: {fdp_str}<br>" f"Reported FDR (PSM level): {fdr_label}<br>" f"FDP / reported FDR: {ratio_str}<br>" f"Category: {row.get('category', '')}" ) if row_is_new: hover_text += "<br><b>&#9733; Newly uploaded</b>" if "Keyword" in plot_df.columns: kw = row.get("Keyword", "") if isinstance(kw, str) and kw.strip(): hover_text += f"<br>Keyword: {kw}" if "submission_comments" in plot_df.columns: comment = row.get("submission_comments", "") if isinstance(comment, str) and comment.strip(): hover_text += f"<br>Comment: {comment}" hover_texts.append(hover_text) plot_df["hover_text"] = hover_texts fig = go.Figure() plot_df["_is_new"] = is_new for category in ("valid", "inconclusive", "invalid"): cat_df = plot_df[plot_df["category"] == category] if cat_df.empty: continue cat_is_new = cat_df["_is_new"].tolist() # New points get a larger marker with a bold border sizes = [20 if n else 12 for n in cat_is_new] border_widths = [2.5 if n else 0.5 for n in cat_is_new] if colorblind_mode: markers_list = [software_markers.get(s, "circle") for s in cat_df["software_name"]] else: markers_list = ["circle-open-dot" if n else "circle" for n in cat_is_new] fig.add_trace( go.Scatter( x=cat_df["software_name"], y=cat_df["fdp_ratio"], mode="markers", name=category.capitalize(), marker=dict( color=category_colors[category], symbol=markers_list, size=sizes, line=dict(width=border_widths, color="rgba(0,0,0,0.5)"), ), hovertext=cat_df["hover_text"].tolist(), hoverinfo="text", ) ) fig.add_hline( y=1.0, line_dash="dash", line_color="gray", annotation_text="FDP = reported FDR", annotation_position="top right", ) fig.update_layout( xaxis=dict( title="Software tool", gridcolor="lightgray", linecolor="black", categoryorder="category ascending", ), yaxis=dict( title=f"{plot_title} / Reported FDR (PSM level)", gridcolor="lightgray", linecolor="black", ), template="plotly_white", height=500, margin=dict(l=80, r=20, t=60, b=100), ) return fig
[docs] def plot_forest( self, result_df: pd.DataFrame, sort_ascending: bool = True, **kwargs, ) -> go.Figure: """ Plot a forest / interval plot of FDP bounds per submitted workflow. Each row represents one submission. A thick horizontal bar spans from the lower FDP bound to the paired upper FDP bound; open circle markers at both endpoints make the interval limits explicit. A diamond marker shows the declared FDR threshold (``reported_fdr_parsed_from_input``). Bar colour indicates the validity category (valid / inconclusive / invalid) computed against each workflow's own ``reported_fdr_parsed_from_input``. Rows are sorted by the number of identified features. Parameters ---------- result_df : pd.DataFrame DataFrame containing all datapoints (one row per workflow). Must include ``lower_bound_FDP``, ``paired_FDP``, ``reported_fdr_parsed_from_input``, ``nr_id_features``, ``software_name``, ``software_version``. sort_ascending : bool, optional Sort rows by ``nr_id_features`` ascending (True) or descending (False). **kwargs : dict Ignored; accepted for call-site compatibility. Returns ------- go.Figure Plotly figure with the forest plot. """ plot_df = result_df.copy() for col in ("lower_bound_FDP", "paired_FDP", "reported_fdr_parsed_from_input"): if col in plot_df.columns: plot_df[col] = pd.to_numeric(plot_df[col], errors="coerce") else: plot_df[col] = np.nan if "nr_id_features" in plot_df.columns: plot_df["nr_id_features"] = pd.to_numeric(plot_df["nr_id_features"], errors="coerce") else: plot_df["nr_id_features"] = np.nan def _categorise_row(row): lower = row.get("lower_bound_FDP", np.nan) upper = row.get("paired_FDP", np.nan) fdr = row.get("reported_fdr_parsed_from_input", np.nan) if pd.isna(fdr) or fdr == 0: fdr = 0.01 if pd.isna(lower) or pd.isna(upper): return "inconclusive" if upper <= fdr: return "valid" elif lower > fdr: return "invalid" return "inconclusive" plot_df["category"] = plot_df.apply(_categorise_row, axis=1) plot_df = plot_df.sort_values("nr_id_features", ascending=sort_ascending, na_position="last").reset_index( drop=True ) def _row_label(row): name = str(row.get("software_name", "")) ver = str(row.get("software_version", "")) base = f"{name} v{ver}" if ver and ver not in ("nan", "0", "") else name return base.strip() raw_labels = plot_df.apply(_row_label, axis=1).tolist() seen: dict = {} unique_labels = [] for lbl in raw_labels: count = seen.get(lbl, 0) seen[lbl] = count + 1 unique_labels.append(f"{lbl} ({count + 1})" if count > 0 else lbl) plot_df["y_label"] = unique_labels n = len(plot_df) y_pos = list(range(n)) plot_df["y_pos"] = y_pos category_colors = {"valid": "#2ecc71", "inconclusive": "#f39c12", "invalid": "#e74c3c"} cap_half = 0.32 hover_texts = [] for _, row in plot_df.iterrows(): lo = row.get("lower_bound_FDP", np.nan) hi = row.get("paired_FDP", np.nan) fdr = row.get("reported_fdr_parsed_from_input", np.nan) nr = row.get("nr_id_features", np.nan) lo_str = f"{lo:.4f}" if pd.notna(lo) else "N/A" hi_str = f"{hi:.4f}" if pd.notna(hi) else "N/A" fdr_str = f"{fdr}" if pd.notna(fdr) and fdr != 0 else "not set (0.01 used)" nr_str = f"{int(nr)}" if pd.notna(nr) else "N/A" hover_texts.append( f"<b>{row.get('id', '')}</b><br>" f"Software: {row.get('software_name', '')} {row.get('software_version', '')}<br>" f"Identified features: {nr_str}<br>" f"Lower FDP bound: {lo_str}<br>" f"Upper FDP bound (paired): {hi_str}<br>" f"Reported FDR (from input): {fdr_str}<br>" f"Category: {row.get('category', '')}" ) plot_df["hover_text"] = hover_texts if "old_new" in plot_df.columns: is_new = (plot_df["old_new"] == "new").tolist() else: is_new = [False] * n fig = go.Figure() # Thick horizontal bars + end caps + endpoint markers, grouped by category for category in ("valid", "inconclusive", "invalid"): cat_df = plot_df[plot_df["category"] == category] if cat_df.empty: continue color = category_colors[category] bar_x, bar_y = [], [] cap_x, cap_y = [], [] ep_x, ep_y = [], [] # endpoint markers at both bounds for _, row in cat_df.iterrows(): lo = row["lower_bound_FDP"] hi = row["paired_FDP"] y = row["y_pos"] if pd.notna(lo) and pd.notna(hi): bar_x += [lo, hi, None] bar_y += [y, y, None] for xv in (lo, hi): cap_x += [xv, xv, None] cap_y += [y - cap_half, y + cap_half, None] ep_x += [lo, hi] ep_y += [y, y] if bar_x: fig.add_trace( go.Scatter( x=bar_x, y=bar_y, mode="lines", name=category.capitalize(), line=dict(color=color, width=10), hoverinfo="skip", legendgroup=category, ) ) if cap_x: fig.add_trace( go.Scatter( x=cap_x, y=cap_y, mode="lines", showlegend=False, line=dict(color=color, width=3), hoverinfo="skip", legendgroup=category, ) ) # Open circle markers at both endpoints to make the bounds explicit if ep_x: fig.add_trace( go.Scatter( x=ep_x, y=ep_y, mode="markers", showlegend=False, marker=dict( symbol="circle", color="white", size=10, line=dict(color=color, width=2.5), ), hoverinfo="skip", legendgroup=category, ) ) # Diamond / star markers at declared FDR (reported_fdr_parsed_from_input), drawn last so they appear on top fdr_x = [ ( r["reported_fdr_parsed_from_input"] if pd.notna(r["reported_fdr_parsed_from_input"]) and r["reported_fdr_parsed_from_input"] > 0 else 0.01 ) for _, r in plot_df.iterrows() ] fdr_missing = [ pd.isna(r["reported_fdr_parsed_from_input"]) or r["reported_fdr_parsed_from_input"] == 0 for _, r in plot_df.iterrows() ] fdr_colors = ["rgba(100,100,100,0.6)" if m else "rgba(20,20,20,0.9)" for m in fdr_missing] new_border_widths = [2.5 if n_ else 1.5 for n_ in is_new] new_sizes = [14 if n_ else 11 for n_ in is_new] fig.add_trace( go.Scatter( x=fdr_x, y=y_pos, mode="markers", name="Declared FDR threshold", marker=dict( symbol=["star" if n_ else "diamond" for n_ in is_new], color=fdr_colors, size=new_sizes, line=dict(width=new_border_widths, color="black"), ), hovertext=plot_df["hover_text"].tolist(), hoverinfo="text", ) ) # Secondary-axis bars: nr_id_features on right side nr_vals = plot_df["nr_id_features"].tolist() nr_colors = [category_colors.get(c, "#cccccc") for c in plot_df["category"].tolist()] fig.add_trace( go.Bar( x=nr_vals, y=y_pos, orientation="h", name="Nr. identified features", marker=dict(color=nr_colors, opacity=0.35), xaxis="x2", hovertemplate="%{x:,} features<extra></extra>", showlegend=False, ) ) row_height = 30 fig_height = max(400, n * row_height + 140) fig.update_layout( xaxis=dict( title="FDP — lower bound to upper bound (paired method)", gridcolor="lightgray", linecolor="black", range=[-0.002, None], domain=[0, 0.55], ), xaxis2=dict( title="Nr. identified features", side="top", overlaying=None, domain=[0.60, 1.0], gridcolor="lightgray", linecolor="black", tickformat=",d", ), yaxis=dict( tickmode="array", tickvals=y_pos, ticktext=plot_df["y_label"].tolist(), gridcolor="lightgray", linecolor="black", ), template="plotly_white", height=fig_height, margin=dict(l=180, r=20, t=80, b=80), ) return fig
[docs] def plot_fdp_id_scatter( self, result_df: pd.DataFrame, software_colors: Dict[str, str] = { "MaxQuant": "#88ccef", "AlphaPept": "#cc6777", "ProlineStudio": "#ddcc77", "MSAngel": "#147733", "FragPipe": "#342288", "i2MassChroQ": "#aa4599", "Sage": "#671100", "WOMBAT": "#44aa9a", "DIA-NN": "#999934", "AlphaDIA": "#1D2732", "Custom": "#000000", "Spectronaut": "#007548", "FragPipe (DIA-NN quant)": "#F89008", "MSAID": "#bfef45", "MetaMorpheus": "#637C7A", "Proteome Discoverer": "#911eb4", "PEAKS": "#f032e6", "quantms": "#f5e830", }, **kwargs, ) -> go.Figure: """ Scatter plot of FDP/FDR ratio (x) vs number of identified features (y). Each point is one submitted workflow. Colour encodes the software tool; marker shape encodes the validity category (valid / inconclusive / invalid) computed from ``paired_FDP`` vs ``reported_fdr_parsed_from_input``. A vertical dashed line at x = 1 marks the point where the empirical FDP equals the declared FDR. Parameters ---------- result_df : pd.DataFrame DataFrame containing all datapoints (one row per workflow). Must include ``paired_FDP``, ``reported_fdr_parsed_from_input``, ``nr_id_features``, ``software_name``, ``software_version``. software_colors : Dict[str, str] Mapping of software names to hex colour strings. **kwargs : dict Ignored; accepted for call-site compatibility. Returns ------- go.Figure Plotly figure. """ category_symbols = {"valid": "circle", "inconclusive": "triangle-up", "invalid": "x"} plot_df = result_df.copy() plot_df["paired_FDP"] = pd.to_numeric(plot_df.get("paired_FDP", np.nan), errors="coerce") plot_df["reported_fdr_parsed_from_input"] = pd.to_numeric( plot_df.get("reported_fdr_parsed_from_input", np.nan), errors="coerce" ) plot_df["nr_id_features"] = pd.to_numeric(plot_df.get("nr_id_features", np.nan), errors="coerce") if "lower_bound_FDP" in plot_df.columns: plot_df["lower_bound_FDP"] = pd.to_numeric(plot_df["lower_bound_FDP"], errors="coerce") else: plot_df["lower_bound_FDP"] = np.nan fdr_effective = plot_df["reported_fdr_parsed_from_input"].copy() fdr_missing = plot_df["reported_fdr_parsed_from_input"].isna() | ( plot_df["reported_fdr_parsed_from_input"] == 0 ) fdr_effective[fdr_missing] = 0.01 plot_df["fdp_ratio"] = plot_df["paired_FDP"] / fdr_effective plot_df["category"] = plot_df["category_paired"].astype(str) if "old_new" in plot_df.columns: is_new = (plot_df["old_new"] == "new").tolist() else: is_new = [False] * len(plot_df) plot_df["_is_new"] = is_new # Build hover texts hover_texts = [] for row_is_new, (_, row) in zip(is_new, plot_df.iterrows()): ratio = row.get("fdp_ratio", np.nan) fdp = row.get("paired_FDP", np.nan) fdr = row.get("reported_fdr_parsed_from_input", np.nan) nr = row.get("nr_id_features", np.nan) ratio_str = f"{ratio:.3f}" if pd.notna(ratio) else "N/A" fdp_str = f"{fdp:.4f}" if pd.notna(fdp) else "N/A" fdr_str = f"{fdr}" if pd.notna(fdr) and fdr != 0 else "not set (0.01 used)" nr_str = f"{int(nr)}" if pd.notna(nr) else "N/A" text = ( f"<b>{row.get('id', '')}</b><br>" f"Software: {row.get('software_name', '')} {row.get('software_version', '')}<br>" f"Identified features: {nr_str}<br>" f"Upper FDP (paired): {fdp_str}<br>" f"Reported FDR (from input): {fdr_str}<br>" f"FDP / FDR: {ratio_str}<br>" f"Category: {row.get('category', '')}" ) if row_is_new: text += "<br><b>&#9733; Newly uploaded</b>" hover_texts.append(text) plot_df["hover_text"] = hover_texts fig = go.Figure() # One trace per software tool (controls colour in the legend) for software_name, sw_df in plot_df.groupby("software_name", dropna=False): color = software_colors.get(str(software_name), "#666666") sizes = [16 if n_ else 10 for n_ in sw_df["_is_new"]] border_widths = [2.5 if n_ else 0.5 for n_ in sw_df["_is_new"]] symbols = [category_symbols.get(c, "circle") for c in sw_df["category"]] fig.add_trace( go.Scatter( x=sw_df["fdp_ratio"], y=sw_df["nr_id_features"], mode="markers", name=str(software_name), legendgroup="software", legendgrouptitle=dict(text="Software"), marker=dict( color=color, symbol=symbols, size=sizes, line=dict(width=border_widths, color="rgba(0,0,0,0.4)"), ), hovertext=sw_df["hover_text"].tolist(), hoverinfo="text", ) ) # Invisible dummy traces to explain shapes in the legend for category, symbol in category_symbols.items(): fig.add_trace( go.Scatter( x=[None], y=[None], mode="markers", name=category.capitalize(), legendgroup="category", legendgrouptitle=dict(text="Category"), marker=dict(color="rgba(80,80,80,0.8)", symbol=symbol, size=10), showlegend=True, ) ) fig.add_vline( x=1.0, line_dash="dash", line_color="gray", annotation_text="upper FDP bound = declared FDR", annotation_position="top right", ) fig.update_layout( xaxis=dict( title="Upper FDP (paired) / Declared FDR", gridcolor="lightgray", linecolor="black", ), yaxis=dict( title="Number of identified features", gridcolor="lightgray", linecolor="black", ), template="plotly_white", height=550, margin=dict(l=80, r=20, t=60, b=80), legend=dict(groupclick="toggleitem"), ) return fig
[docs] def plot_category_strip( self, result_df: pd.DataFrame, software_colors: Dict[str, str] = { "MaxQuant": "#88ccef", "AlphaPept": "#cc6777", "ProlineStudio": "#ddcc77", "MSAngel": "#147733", "FragPipe": "#342288", "i2MassChroQ": "#aa4599", "Sage": "#671100", "WOMBAT": "#44aa9a", "DIA-NN": "#999934", "AlphaDIA": "#1D2732", "Custom": "#000000", "Spectronaut": "#007548", "FragPipe (DIA-NN quant)": "#F89008", "MSAID": "#bfef45", "MetaMorpheus": "#637C7A", "Proteome Discoverer": "#911eb4", "PEAKS": "#f032e6", "quantms": "#f5e830", }, **kwargs, ) -> go.Figure: """ Strip plot of number of identified features grouped by FDP validity category. Points are distributed within each category column using evenly-spaced horizontal jitter (sorted by ``nr_id_features`` within each group so the ordering is deterministic and reproducible). Background shading distinguishes categories: light red = invalid, light yellow = inconclusive, light green = valid. Point colour encodes the software tool. Parameters ---------- result_df : pd.DataFrame DataFrame containing all datapoints (one row per workflow). Must include ``lower_bound_FDP``, ``paired_FDP``, ``reported_fdr_parsed_from_input``, ``nr_id_features``, ``software_name``, ``software_version``. software_colors : Dict[str, str] Mapping of software names to hex colour strings. **kwargs : dict Ignored; accepted for call-site compatibility. Returns ------- go.Figure Plotly figure. """ category_order = ["invalid", "inconclusive", "valid"] x_centers = {cat: i for i, cat in enumerate(category_order)} bg_colors = { "invalid": "rgba(231,76,60,0.13)", "inconclusive": "rgba(241,196,15,0.18)", "valid": "rgba(46,204,113,0.13)", } plot_df = result_df.copy() for col in ("lower_bound_FDP", "paired_FDP", "reported_fdr_parsed_from_input"): if col in plot_df.columns: plot_df[col] = pd.to_numeric(plot_df[col], errors="coerce") else: plot_df[col] = np.nan if "nr_id_features" in plot_df.columns: plot_df["nr_id_features"] = pd.to_numeric(plot_df["nr_id_features"], errors="coerce") else: plot_df["nr_id_features"] = np.nan def _categorise_row(row): lower = row.get("lower_bound_FDP", np.nan) upper = row.get("paired_FDP", np.nan) fdr = row.get("reported_fdr_parsed_from_input", np.nan) if pd.isna(fdr) or fdr == 0: fdr = 0.01 if pd.isna(lower) or pd.isna(upper): return "inconclusive" if upper <= fdr: return "valid" elif lower > fdr: return "invalid" return "inconclusive" plot_df["category"] = plot_df.apply(_categorise_row, axis=1) if "old_new" in plot_df.columns: is_new = (plot_df["old_new"] == "new").tolist() else: is_new = [False] * len(plot_df) plot_df["_is_new"] = is_new # Assign jitter: within each category, sort by nr_id_features then spread evenly jitter_width = 0.35 plot_df["x_pos"] = np.nan for cat in category_order: mask = plot_df["category"] == cat grp = plot_df[mask].sort_values("nr_id_features", na_position="last") n = len(grp) if n == 0: continue offsets = np.linspace(-jitter_width, jitter_width, n) if n > 1 else np.zeros(1) plot_df.loc[grp.index, "x_pos"] = x_centers[cat] + offsets # Build hover texts hover_texts = [] for row_is_new, (_, row) in zip(plot_df["_is_new"].tolist(), plot_df.iterrows()): lo = row.get("lower_bound_FDP", np.nan) hi = row.get("paired_FDP", np.nan) fdr = row.get("reported_fdr_parsed_from_input", np.nan) nr = row.get("nr_id_features", np.nan) text = ( f"<b>{row.get('id', '')}</b><br>" f"Software: {row.get('software_name', '')} {row.get('software_version', '')}<br>" f"Identified features: {int(nr) if pd.notna(nr) else 'N/A'}<br>" f"Lower FDP: {f'{lo:.4f}' if pd.notna(lo) else 'N/A'}<br>" f"Upper FDP (paired): {f'{hi:.4f}' if pd.notna(hi) else 'N/A'}<br>" f"Reported FDR (from input): {f'{fdr}' if pd.notna(fdr) and fdr != 0 else 'not set (0.01 used)'}<br>" f"Category: {row.get('category', '')}" ) if row_is_new: text += "<br><b>&#9733; Newly uploaded</b>" hover_texts.append(text) plot_df["hover_text"] = hover_texts fig = go.Figure() # Background shading per category (drawn first so points appear on top) for cat in category_order: center = x_centers[cat] fig.add_vrect( x0=center - 0.5, x1=center + 0.5, fillcolor=bg_colors[cat], layer="below", line_width=0, ) # One trace per software tool (controls the colour legend) for software_name, sw_df in plot_df.groupby("software_name", dropna=False): color = software_colors.get(str(software_name), "#666666") sizes = [16 if n_ else 10 for n_ in sw_df["_is_new"]] border_widths = [2.5 if n_ else 0.5 for n_ in sw_df["_is_new"]] fig.add_trace( go.Scatter( x=sw_df["x_pos"], y=sw_df["nr_id_features"], mode="markers", name=str(software_name), marker=dict( color=color, symbol="circle", size=sizes, line=dict(width=border_widths, color="rgba(0,0,0,0.4)"), ), hovertext=sw_df["hover_text"].tolist(), hoverinfo="text", ) ) fig.update_layout( xaxis=dict( tickmode="array", tickvals=[0, 1, 2], ticktext=["Invalid", "Inconclusive", "Valid"], range=[-0.5, 2.5], gridcolor="lightgray", linecolor="black", title="Validity category", ), yaxis=dict( title="Number of identified features", gridcolor="lightgray", linecolor="black", ), template="plotly_white", height=500, margin=dict(l=80, r=20, t=60, b=80), ) return fig
def _resolve_metric_column(self, metric: str) -> Tuple[str, str]: """ Resolve the metric column name and plot title based on the selected metric. Parameters ---------- metric : str The selected metric to plot. Returns ------- Tuple[str, str] A tuple containing the resolved metric column name and the corresponding plot title. """ if metric == "Lower FDP bound": return "lower_bound_FDP", "Lower FDP bound" elif metric == "Upper FDP bound - Combined method": return "combined_FDP", "Upper FDP bound - Combined method" elif metric == "Upper FDP bound - Paired method": return "paired_FDP", "Upper FDP bound - Paired method" else: raise ValueError(f"Unsupported metric '{metric}' selected for plotting.")
[docs] def plot_main_metric( self, result_df: pd.DataFrame, hide_annot: bool = False, metric: str = "Upper FDP bound - Paired method", colorblind_mode: bool = False, software_colors: Dict[str, str] = { "MaxQuant": "#88ccef", "AlphaPept": "#cc6777", "ProlineStudio": "#ddcc77", "MSAngel": "#147733", "FragPipe": "#342288", "i2MassChroQ": "#aa4599", "Sage": "#671100", "WOMBAT": "#44aa9a", "DIA-NN": "#999934", "AlphaDIA": "#1D2732", "Custom": "#000000", "Spectronaut": "#007548", "FragPipe (DIA-NN quant)": "#F89008", "MSAID": "#bfef45", "MetaMorpheus": "#637C7A", "Proteome Discoverer": "#911eb4", "PEAKS": "#f032e6", "quantms": "#f5e830", }, software_markers: Dict[str, str] = { "MaxQuant": "circle", "AlphaPept": "square", "ProlineStudio": "diamond", "MSAngel": "cross", "FragPipe": "x", "i2MassChroQ": "triangle-up", "Sage": "triangle-down", "WOMBAT": "pentagon", "DIA-NN": "star", "AlphaDIA": "star-triangle-up", "Custom": "star-square", "Spectronaut": "diamond-tall", "FragPipe (DIA-NN quant)": "circle-x", "MSAID": "square-cross", "MetaMorpheus": "asterisk", "Proteome Discoverer": "hash", "PEAKS": "diamond-wide", "quantms": "hexagram", }, mapping: Dict[str, str] = {"old": 10, "new": 20}, highlight_color: str = "#d30067", label: str = "", legend_name_map: Dict[str, str] = {"AlphaPept": "AlphaPept (legacy tool)"}, annotation: str = "", **kwargs, ) -> go.Figure: """ Generate the main performance metric scatter plot for entrapment modules. Parameters ---------- result_df : pd.DataFrame DataFrame containing the results to plot. metric : str, optional Bound to plot on the x axis, one of "Lower FDP bound", "Upper FDP bound - Combined method", or "Upper FDP bound - Paired method". colorblind_mode : Bool, optional If True, use different shapes for workflows. software_colors : Dict[str, str] Mapping of software names to colors. software_markers : Dict[str, str] Mapping of software names to markers. mapping : Dict[str, str] Mapping for renaming software versions. highlight_color : str Color to use for highlighting a specific software/tool. label : str Label for the highlighted software/tool. legend_name_map : Dict[str, str] Mapping for legend names. hide_annot : bool Whether to hide annotations on the plot. **kwargs : dict Additional module-specific parameters. Returns ------- go.Figure Plotly figure with the main performance metric plot. """ metric_col_name, plot_title = self._resolve_metric_column(metric) if metric_col_name not in result_df.columns: raise KeyError(f"Missing metric column '{metric_col_name}' in result dataframe") if "nr_id_features" not in result_df.columns: raise KeyError("Missing 'nr_id_features' in result dataframe") plot_df = result_df.copy() plot_df[metric_col_name] = pd.to_numeric(plot_df[metric_col_name], errors="coerce") plot_df["nr_id_features"] = pd.to_numeric(plot_df["nr_id_features"], errors="coerce") hover_texts = [] for _, row in plot_df.iterrows(): hover_text = ( f"ProteoBench ID: {row.get('id', '')}<br>" f"Software tool: {row.get('software_name', '')} {row.get('software_version', '')}<br>" f"{plot_title}: {row.get(metric_col_name, '')}<br>" f"Identified features: {row.get('nr_id_features', '')}" ) if "Keyword" in plot_df.columns: keyword = row.get("Keyword", "") if isinstance(keyword, str) and keyword.strip(): hover_text += f"<br>Keyword: {keyword}" if "comments" in plot_df.columns: comment = row.get("comments", "") if isinstance(comment, str) and comment.strip(): hover_text += f"<br>Comment (private submission): {comment}" if "submission_comments" in plot_df.columns: comment = row.get("submission_comments", "") if isinstance(comment, str) and comment.strip(): hover_text += f"<br>Comment (public submission): {comment}" hover_texts.append(hover_text) plot_df["hover_text"] = hover_texts colors = [software_colors.get(software, "#000000") for software in plot_df["software_name"]] markers = [software_markers.get(software, "circle") for software in plot_df["software_name"]] if "Highlight" in plot_df.columns: colors = [highlight_color if highlight else color for color, highlight in zip(colors, plot_df["Highlight"])] plot_df["color"] = colors plot_df["marker"] = markers if colorblind_mode else ["circle"] * len(plot_df) if "old_new" in plot_df.columns: scatter_sizes = [mapping.get(str(item), 10) for item in plot_df["old_new"]] else: scatter_sizes = [10] * len(plot_df) if "Highlight" in plot_df.columns: scatter_sizes = [ size * 2 if highlight else size for size, highlight in zip(scatter_sizes, plot_df["Highlight"]) ] plot_df["scatter_size"] = scatter_sizes fig = go.Figure() for software_name, software_df in plot_df.groupby("software_name", dropna=False): software_label = legend_name_map.get(software_name, software_name) fig.add_trace( go.Scatter( x=software_df[metric_col_name], y=software_df["nr_id_features"], mode="markers" if label == "None" else "markers+text", text=software_df[label].tolist() if label != "None" and label in software_df.columns else None, textposition="top center" if label != "None" else None, hovertext=software_df["hover_text"].tolist(), hoverinfo="text", name=software_label, legendgroup=software_name, marker=dict( color=software_df["color"].tolist(), symbol=software_df["marker"].tolist(), size=software_df["scatter_size"].tolist(), line=dict(width=0.5, color="rgba(0,0,0,0.25)"), ), ) ) fig.update_layout( width=None, height=700, autosize=True, xaxis=dict( title=plot_title, gridcolor="lightgray", gridwidth=1, linecolor="black", ), yaxis=dict( title="Number of identified features", gridcolor="lightgray", gridwidth=1, linecolor="black", ), margin=dict(l=80, r=20, t=50, b=80), clickmode="event+select", ) # fig.update_xaxes(range=[0, 0.5]) if annotation: fig.add_annotation( x=0.5, y=0.5, xref="paper", yref="paper", text=annotation, font=dict(size=50, color="rgba(0,0,0,0.1)"), showarrow=False, ) # add orientation line at 1% FDR fig.add_shape( type="line", x0=0.01, y0=0, x1=0.01, y1=plot_df["nr_id_features"].max() * 1.1, line=dict(color="red", width=2, dash="dash"), ) return fig