Skip to content

Visualization

visualization

__all__ = ['plot_feature_importance', 'visualize_target_decoy_features', 'visualize_feature_correlation', 'save_or_show_plot', 'plot_qvalues'] module-attribute

plot_feature_importance(models, rescoring_features, save_path=None, sort=False, error=False, **kwargs)

Unified function to plot average feature importance across multiple models.

This function supports: - Linear models (e.g., Linear SVR) which provide an 'estimator' attribute with a 'coef_'. The absolute value of the coefficients is used for importance, and hatch patterns are applied to differentiate between positive and negative coefficients. - XGBoost models which provide a 'feature_importances_' attribute. Since these values are always positive, no hatch patterns are applied.

Parameters:

Name Type Description Default
models list

A list of model objects. For linear models, each model should have an 'estimator' with 'coef_'. For XGBoost models, each model should have a 'feature_importances_' attribute.

required
rescoring_features dict

A dictionary where keys are sources and values are lists of features.

required
save_path str

If provided, saves the plot to the specified path.

None
sort bool

If True, sorts the features by their importance in descending order. Default is False.

False
error bool

If True, adds error bars to the plot. Default is False.

False
**kwargs dict

Additional plotting parameters: - 'figsize' : tuple, default (15, 10) Figure size in inches (width, height). - 'dpi' : int, default 300 Resolution in dots per inch. - 'palette' : str, default 'crest' Seaborn color palette name. Options include 'crest', 'flare', 'mako', 'rocket', 'tab10', 'husl', 'Set2', etc.

{}
Notes

The function automatically detects the model type based on the presence of the corresponding attribute. For linear models, it uses hatch patterns to differentiate between positive and negative coefficients. For XGBoost models, it uses solid bars since the importances are always positive.

The color palette is automatically scaled to match the number of feature sources, ensuring consistent colors between the bars and legend.

Examples:

>>> # Use default crest palette
>>> plot_feature_importance(models, rescoring_features, save_path='importance.png')
>>> # Use a different palette
>>> plot_feature_importance(models, rescoring_features, palette='flare', sort=True, error=True)
Source code in optimhc/visualization/plot_features.py
def plot_feature_importance(
    models, rescoring_features, save_path=None, sort=False, error=False, **kwargs
):
    """
    Unified function to plot average feature importance across multiple models.

    This function supports:
      - Linear models (e.g., Linear SVR) which provide an 'estimator' attribute with a 'coef_'.
        The absolute value of the coefficients is used for importance, and hatch patterns are applied
        to differentiate between positive and negative coefficients.
      - XGBoost models which provide a 'feature_importances_' attribute. Since these values are
        always positive, no hatch patterns are applied.

    Parameters
    ----------
    models : list
        A list of model objects.
        For linear models, each model should have an 'estimator' with 'coef_'.
        For XGBoost models, each model should have a 'feature_importances_' attribute.
    rescoring_features : dict
        A dictionary where keys are sources and values are lists of features.
    save_path : str, optional
        If provided, saves the plot to the specified path.
    sort : bool, optional
        If True, sorts the features by their importance in descending order.
        Default is False.
    error : bool, optional
        If True, adds error bars to the plot. Default is False.
    **kwargs : dict
        Additional plotting parameters:
        - 'figsize' : tuple, default (15, 10)
            Figure size in inches (width, height).
        - 'dpi' : int, default 300
            Resolution in dots per inch.
        - 'palette' : str, default 'crest'
            Seaborn color palette name. Options include 'crest', 'flare', 'mako',
            'rocket', 'tab10', 'husl', 'Set2', etc.

    Notes
    -----
    The function automatically detects the model type based on the presence of the corresponding attribute.
    For linear models, it uses hatch patterns to differentiate between positive and negative coefficients.
    For XGBoost models, it uses solid bars since the importances are always positive.

    The color palette is automatically scaled to match the number of feature sources, ensuring
    consistent colors between the bars and legend.

    Examples
    --------
    >>> # Use default crest palette
    >>> plot_feature_importance(models, rescoring_features, save_path='importance.png')

    >>> # Use a different palette
    >>> plot_feature_importance(models, rescoring_features, palette='flare', sort=True, error=True)
    """
    # Determine the model type based on the first model in the list.
    if hasattr(models[0].estimator, "coef_"):
        model_type = "linear"
    elif hasattr(models[0].estimator, "feature_importances_"):
        model_type = "xgb"
    else:
        raise ValueError(
            "Model type not recognized. Model must have 'estimator.coef_' for linear models or "
            "'estimator.feature_importances_' for XGBoost models."
        )

    if model_type == "linear":
        feature_importances = []
        for model in models:
            coefficients = model.estimator.coef_
            feature_importances.append(np.abs(coefficients).mean(axis=0))
            logger.debug(f"Model coefficients shape: {coefficients.shape}")

        average_feature_importance = np.mean(feature_importances, axis=0)
        std_feature_importance = np.std(feature_importances, axis=0)
        feature_signs = np.mean([model.estimator.coef_.mean(axis=0) for model in models], axis=0)

    elif model_type == "xgb":
        feature_importances = []
        for model in models:
            # Use the XGBoost feature importances directly as they are always positive
            imp = model.estimator.feature_importances_
            feature_importances.append(imp)
            logger.debug(f"Model feature importances shape: {imp.shape}")

        average_feature_importance = np.mean(feature_importances, axis=0)
        std_feature_importance = np.std(feature_importances, axis=0)
        feature_signs = np.ones_like(average_feature_importance)

    logger.debug(f"Total rescoring features: {len(sum(rescoring_features.values(), []))}")
    logger.debug(f"Average feature importance length: {len(average_feature_importance)}")
    logger.debug(f"Features: {sum(rescoring_features.values(), [])}")

    # Extract plotting parameters
    figsize = kwargs.get("figsize", (15, 10))
    dpi = kwargs.get("dpi", 300)
    palette_name = kwargs.get("palette", "Set2")

    all_features = []
    all_importances = []
    all_errors = []
    all_colors = []
    all_hatches = []  # Hatch patterns will be applied only for linear models.

    n_sources = len(rescoring_features)
    colors = sns.color_palette(palette_name, n_colors=n_sources)
    source_colors = dict(zip(rescoring_features.keys(), colors))

    for source, features in rescoring_features.items():
        color = source_colors[source]
        indices = [
            i for i, name in enumerate(sum(rescoring_features.values(), [])) if name in features
        ]
        source_importances = average_feature_importance[indices]
        source_std = std_feature_importance[indices]

        if model_type == "linear":
            source_signs = feature_signs[indices]

        if sort:
            sorted_indices = np.argsort(-source_importances)
        else:
            sorted_indices = np.arange(len(features))

        sorted_features = [features[i] for i in sorted_indices]
        sorted_importances = source_importances[sorted_indices]
        sorted_std = source_std[sorted_indices]

        all_features.extend(sorted_features)
        all_importances.extend(sorted_importances)
        all_errors.extend(sorted_std)
        all_colors.extend([color] * len(sorted_features))

        if model_type == "linear":
            # For linear models, use hatch patterns to differentiate positive and negative coefficients.
            # An empty hatch ('') for positive and '\\' for negative coefficients.
            sorted_signs = source_signs[sorted_indices]
            all_hatches.extend(["" if sign >= 0 else "\\\\" for sign in sorted_signs])
        else:
            all_hatches.extend([""] * len(sorted_features))

    fig, ax = plt.subplots(figsize=figsize, dpi=dpi)
    if error:
        bars = ax.barh(all_features, all_importances, xerr=all_errors, color=all_colors, capsize=5)
    else:
        bars = ax.barh(all_features, all_importances, color=all_colors)

    if model_type == "linear":
        for bar, hatch in zip(bars, all_hatches):
            bar.set_hatch(hatch)
        legend_hatches = [
            Patch(facecolor="white", edgecolor="black", hatch="", label="Positive"),
            Patch(facecolor="white", edgecolor="black", hatch="\\\\", label="Negative"),
        ]
        legend_colors = [
            Patch(facecolor=source_colors[source], edgecolor="black", label=source)
            for source in rescoring_features.keys()
        ]
        ax.legend(handles=legend_hatches + legend_colors, loc="best")
    else:
        legend_colors = [
            Patch(facecolor=source_colors[source], edgecolor="black", label=source)
            for source in rescoring_features.keys()
        ]
        ax.legend(handles=legend_colors, loc="best")

    ax.set_xlabel("Average Feature Importance")
    ax.set_ylabel("Feature")

    save_or_show_plot(save_path, logger)

visualize_feature_correlation(psms, save_path=None, **kwargs)

Visualize the correlation between features in a DataFrame using a scatter plot heatmap.

Parameters:

Name Type Description Default
psms PsmContainer

A PsmContainer object containing the features to visualize.

required
save_path str

The file path to save the plot. If not provided, the plot is displayed.

None
**kwargs dict

Additional plotting parameters such as figsize, dpi, height, etc.

{}
Source code in optimhc/visualization/plot_features.py
def visualize_feature_correlation(psms: PsmContainer, save_path=None, **kwargs):
    """
    Visualize the correlation between features in a DataFrame using a scatter plot heatmap.

    Parameters
    ----------
    psms : PsmContainer
        A PsmContainer object containing the features to visualize.
    save_path : str, optional
        The file path to save the plot. If not provided, the plot is displayed.
    **kwargs : dict
        Additional plotting parameters such as `figsize`, `dpi`, `height`, etc.
    """
    rescoring_features = [item for sublist in psms.rescoring_features.values() for item in sublist]
    n_features = len(rescoring_features)

    default_height = max(8, min(20, 8 + n_features * 0.15))
    height = kwargs.get("height", default_height)
    dpi = kwargs.get("dpi", 300)

    corr = psms.psms[rescoring_features].corr()
    corr_mat = corr.stack().reset_index(name="correlation")

    g = sns.relplot(
        data=corr_mat,
        x="level_0",
        y="level_1",
        hue="correlation",
        size="correlation",
        palette="vlag",
        hue_norm=(-1, 1),
        edgecolor=".7",
        height=height,
        sizes=(50, 250),
        size_norm=(-0.2, 0.8),
        **{k: v for k, v in kwargs.items() if k not in ["height", "dpi", "figsize"]},
    )

    g.set(xlabel="", ylabel="", aspect="equal")
    g.despine(left=True, bottom=True)
    g.ax.margins(0.02)

    for label in g.ax.get_xticklabels():
        label.set_rotation(90)

    if n_features > 30:
        fontsize = max(6, 10 - n_features * 0.05)
        g.ax.tick_params(labelsize=fontsize)

    g.figure.suptitle("Feature Correlation Matrix", y=1.01, fontsize=14)
    plt.tight_layout()
    g.figure.dpi = dpi

    save_or_show_plot(save_path, logger)

plot_qvalues(results, save_path=None, dpi=300, figsize=(15, 10), threshold=0.05, colors=None, **kwargs)

Plot q-values for the given results.

Parameters:

Name Type Description Default
results object or list

A list of results objects or a single result object. Each result object should have a method plot_qvalues.

required
save_path str

If provided, saves the plot to the specified path.

None
dpi int

The resolution of the plot. Default is 300.

300
figsize tuple

The size of the figure. Default is (15, 10).

(15, 10)
threshold float

The q-value threshold for plotting. Default is 0.05.

0.05
colors list

A list of colors for the plots. If not provided, uses default colors.

None
**kwargs dict

Additional plotting parameters.

{}

Returns:

Type Description
None

The function displays or saves the plot.

Notes

This function: 1. Creates a figure with two subplots for PSMs and peptides 2. Plots q-values for each result with different colors 3. Adds legends and titles to each subplot 4. Saves or displays the plot based on save_path

Source code in optimhc/visualization/plot_roc.py
def plot_qvalues(
    results,
    save_path=None,
    dpi=300,
    figsize=(15, 10),
    threshold=0.05,
    colors=None,
    **kwargs,
):
    """
    Plot q-values for the given results.

    Parameters
    ----------
    results : object or list
        A list of results objects or a single result object.
        Each result object should have a method `plot_qvalues`.
    save_path : str, optional
        If provided, saves the plot to the specified path.
    dpi : int, optional
        The resolution of the plot. Default is 300.
    figsize : tuple, optional
        The size of the figure. Default is (15, 10).
    threshold : float, optional
        The q-value threshold for plotting. Default is 0.05.
    colors : list, optional
        A list of colors for the plots. If not provided, uses default colors.
    **kwargs : dict
        Additional plotting parameters.

    Returns
    -------
    None
        The function displays or saves the plot.

    Notes
    -----
    This function:
    1. Creates a figure with two subplots for PSMs and peptides
    2. Plots q-values for each result with different colors
    3. Adds legends and titles to each subplot
    4. Saves or displays the plot based on save_path
    """
    if not isinstance(results, list):
        results = [results]

    if colors is None:
        colors = [
            "#1f77b4",
            "#ff7f0e",
            "#2ca02c",
            "#d62728",
            "#9467bd",
            "#8c564b",
            "#e377c2",
            "#7f7f7f",
            "#bcbd22",
            "#17becf",
        ]

    fig, axs = plt.subplots(1, 2, figsize=figsize, dpi=dpi)

    for i, result in enumerate(results):
        for ax, level in zip(axs, ["psms", "peptides"]):
            result.plot_qvalues(
                level=level,
                c=colors[i % len(colors)],
                ax=ax,
                threshold=threshold,
                label=f"Result {i + 1}" if len(results) > 1 else "Results",
                linewidth=1,
                **kwargs,
            )
            ax.legend(frameon=False)
            ax.set_title(f"{level}")

    plt.tight_layout()
    return save_or_show_plot(save_path, logger)

visualize_target_decoy_features(psms, num_cols=5, save_path=None, **kwargs)

Visualize the distribution of features in a DataFrame using kernel density estimation plots.

Parameters:

Name Type Description Default
psms PsmContainer

A PsmContainer object containing the features to visualize.

required
num_cols int

The number of columns in the plot grid. Default is 5.

5
save_path str

The file path to save the plot. If not provided, the plot is displayed.

None
**kwargs dict

Additional plotting parameters such as figsize and dpi, etc.

{}
Notes

This function: 1. Extracts rescoring features from the PsmContainer 2. Filters out features with only one unique value 3. Creates a grid of plots showing the distribution of each feature 4. Separates target and decoy PSMs in each plot 5. Uses kernel density estimation to show the distribution shape

Source code in optimhc/visualization/plot_tdc_distribution.py
def visualize_target_decoy_features(psms: PsmContainer, num_cols=5, save_path=None, **kwargs):
    """
    Visualize the distribution of features in a DataFrame using kernel density estimation plots.

    Parameters
    ----------
    psms : PsmContainer
        A PsmContainer object containing the features to visualize.
    num_cols : int, optional
        The number of columns in the plot grid. Default is 5.
    save_path : str, optional
        The file path to save the plot. If not provided, the plot is displayed.
    **kwargs : dict
        Additional plotting parameters such as `figsize` and `dpi`, etc.

    Notes
    -----
    This function:
    1. Extracts rescoring features from the PsmContainer
    2. Filters out features with only one unique value
    3. Creates a grid of plots showing the distribution of each feature
    4. Separates target and decoy PSMs in each plot
    5. Uses kernel density estimation to show the distribution shape
    """
    rescoring_features = [
        item
        for sublist in psms.rescoring_features.values()
        for item in sublist
        if item != psms.hit_rank_column
    ]

    # drop features that only have one value
    rescoring_features = [
        feature for feature in rescoring_features if len(psms.psms[feature].unique()) > 1
    ]

    num_features = len(rescoring_features)
    num_rows = (num_features + num_cols - 1) // num_cols

    figsize = kwargs.get("figsize", (15, num_rows * 15 / num_cols))
    dpi = kwargs.get("dpi", 300)

    fig, axes = plt.subplots(num_rows, num_cols, figsize=figsize, dpi=dpi)
    axes = axes.flatten()

    psms_top_hits = psms.psms[psms.psms[psms.hit_rank_column] == 1].copy()
    num_true_hits = len(psms_top_hits[psms_top_hits[psms.label_column]])
    num_decoys = len(psms_top_hits[~psms_top_hits[psms.label_column]])
    logger.debug(f"Number of true hits: {num_true_hits}")
    logger.debug(f"Number of decoys: {num_decoys}")
    psms_top_hits[psms.label_column] = psms_top_hits[psms.label_column].map(
        {True: "Target", False: "Decoy"}
    )

    for i, feature in enumerate(rescoring_features):
        try:
            ax = axes[i]
            sns.histplot(
                data=psms_top_hits,
                x=feature,
                hue=psms.label_column,
                ax=ax,
                bins="auto",
                common_bins=True,
                multiple="dodge",
                fill=True,
                alpha=0.3,
                stat="frequency",
                kde=True,
                linewidth=0,
            )
            ax.set_title(feature)
            ax.set_xlabel("")
            ax.set_ylabel("")

        except Exception as e:
            logger.error(f"Error plotting feature {feature}: {e}")
            ax.set_visible(False)

    for j in range(i + 1, len(axes)):
        fig.delaxes(axes[j])

    save_or_show_plot(save_path, logger)

Q-value Plots

plot_roc

logger = logging.getLogger(__name__) module-attribute

save_or_show_plot(save_path, logger, tight_layout=True)

Source code in optimhc/visualization/save_or_show_plot.py
def save_or_show_plot(save_path, logger, tight_layout=True):
    if tight_layout:
        plt.tight_layout()
    if save_path:
        os.makedirs(os.path.dirname(save_path), exist_ok=True)
        plt.savefig(save_path, bbox_inches="tight")
        logger.info(f"Plot saved to {save_path}")
    else:
        plt.show()
    plt.close("all")

plot_qvalues(results, save_path=None, dpi=300, figsize=(15, 10), threshold=0.05, colors=None, **kwargs)

Plot q-values for the given results.

Parameters:

Name Type Description Default
results object or list

A list of results objects or a single result object. Each result object should have a method plot_qvalues.

required
save_path str

If provided, saves the plot to the specified path.

None
dpi int

The resolution of the plot. Default is 300.

300
figsize tuple

The size of the figure. Default is (15, 10).

(15, 10)
threshold float

The q-value threshold for plotting. Default is 0.05.

0.05
colors list

A list of colors for the plots. If not provided, uses default colors.

None
**kwargs dict

Additional plotting parameters.

{}

Returns:

Type Description
None

The function displays or saves the plot.

Notes

This function: 1. Creates a figure with two subplots for PSMs and peptides 2. Plots q-values for each result with different colors 3. Adds legends and titles to each subplot 4. Saves or displays the plot based on save_path

Source code in optimhc/visualization/plot_roc.py
def plot_qvalues(
    results,
    save_path=None,
    dpi=300,
    figsize=(15, 10),
    threshold=0.05,
    colors=None,
    **kwargs,
):
    """
    Plot q-values for the given results.

    Parameters
    ----------
    results : object or list
        A list of results objects or a single result object.
        Each result object should have a method `plot_qvalues`.
    save_path : str, optional
        If provided, saves the plot to the specified path.
    dpi : int, optional
        The resolution of the plot. Default is 300.
    figsize : tuple, optional
        The size of the figure. Default is (15, 10).
    threshold : float, optional
        The q-value threshold for plotting. Default is 0.05.
    colors : list, optional
        A list of colors for the plots. If not provided, uses default colors.
    **kwargs : dict
        Additional plotting parameters.

    Returns
    -------
    None
        The function displays or saves the plot.

    Notes
    -----
    This function:
    1. Creates a figure with two subplots for PSMs and peptides
    2. Plots q-values for each result with different colors
    3. Adds legends and titles to each subplot
    4. Saves or displays the plot based on save_path
    """
    if not isinstance(results, list):
        results = [results]

    if colors is None:
        colors = [
            "#1f77b4",
            "#ff7f0e",
            "#2ca02c",
            "#d62728",
            "#9467bd",
            "#8c564b",
            "#e377c2",
            "#7f7f7f",
            "#bcbd22",
            "#17becf",
        ]

    fig, axs = plt.subplots(1, 2, figsize=figsize, dpi=dpi)

    for i, result in enumerate(results):
        for ax, level in zip(axs, ["psms", "peptides"]):
            result.plot_qvalues(
                level=level,
                c=colors[i % len(colors)],
                ax=ax,
                threshold=threshold,
                label=f"Result {i + 1}" if len(results) > 1 else "Results",
                linewidth=1,
                **kwargs,
            )
            ax.legend(frameon=False)
            ax.set_title(f"{level}")

    plt.tight_layout()
    return save_or_show_plot(save_path, logger)

Feature Plots

plot_features

logger = logging.getLogger(__name__) module-attribute

PsmContainer(psms, label_column, scan_column, spectrum_column, ms_data_file_column, peptide_column, protein_column, rescoring_features, hit_rank_column=None, charge_column=None, retention_time_column=None, calculated_mass_column=None, metadata_column=None)

A container for managing peptide-spectrum matches (PSMs) in immunopeptidomics rescoring pipelines.

Parameters:

Name Type Description Default
psms DataFrame

DataFrame containing the PSM data.

required
label_column str

Column containing the label (True for target, False for decoy).

required
scan_column str

Column containing the scan number.

required
spectrum_column str

Column containing the spectrum identifier.

required
ms_data_file_column str

Column containing the MS data file that the PSM originated from.

required
peptide_column str

Column containing the peptide sequence.

required
protein_column str

Column containing the protein accessions.

required
rescoring_features dict of str to list of str

Dictionary of feature columns for rescoring.

required
hit_rank_column str

Column containing the hit rank.

None
charge_column str

Column containing the charge state.

None
retention_time_column str

Column containing the retention time.

None
calculated_mass_column str

Column containing the calculated mass.

None
metadata_column str

Column containing metadata.

None

Attributes:

Name Type Description
psms DataFrame

Copy of the DataFrame containing the PSM data.

target_psms DataFrame

DataFrame containing only target PSMs (label = True).

decoy_psms DataFrame

DataFrame containing only decoy PSMs (label = False).

peptides list of str

List containing all peptides from the PSM data.

columns list of str

List of column names in the PSM DataFrame.

rescoring_features dict of str to list of str

Dictionary of rescoring feature columns in the PSM DataFrame.

Source code in optimhc/psm_container.py
def __init__(
    self,
    psms: pd.DataFrame,
    label_column: str,
    scan_column: str,
    spectrum_column: str,
    ms_data_file_column: str,
    peptide_column: str,
    protein_column: str,
    rescoring_features: Dict[str, List[str]],
    hit_rank_column: Optional[str] = None,
    charge_column: Optional[str] = None,
    retention_time_column: Optional[str] = None,
    calculated_mass_column: Optional[str] = None,
    metadata_column: Optional[str] = None,
):
    self._psms = psms.copy()
    self._psms.reset_index(drop=True, inplace=True)
    self.label_column = label_column
    self.scan_column = scan_column
    self.spectrum_column = spectrum_column
    self.ms_data_file_column = ms_data_file_column
    self.peptide_column = peptide_column
    self.protein_column = protein_column
    self.hit_rank_column = hit_rank_column
    self.retention_time_column = retention_time_column
    self.metadata_column = metadata_column
    self.rescoring_features = rescoring_features
    self.charge_column = charge_column
    self.calculated_mass_column = calculated_mass_column
    # rescore result column
    self.rescore_result_column = None

    # check if the columns are in the dataframe
    def check_column(col):
        if col and col not in psms.columns:
            raise ValueError(f"Column '{col}' not found in PSM data.")

    check_column(label_column)
    check_column(scan_column)
    check_column(spectrum_column)
    check_column(ms_data_file_column)
    check_column(peptide_column)
    check_column(protein_column)
    check_column(hit_rank_column)
    check_column(retention_time_column)
    check_column(charge_column)
    check_column(calculated_mass_column)

    # ensure the label column is boolean
    if psms[label_column].dtype != "bool":
        raise ValueError(f"Column '{label_column}' must be boolean.")

    if psms[label_column].nunique() == 1 and psms[label_column].iloc[0]:
        raise ValueError("All PSMs are labeled as target. No decoy PSMs found.")
    elif psms[label_column].nunique() == 1 and not psms[label_column].iloc[0]:
        raise ValueError("All PSMs are labeled as decoy. No target PSMs found.")

    def check_metadata_column(col):
        # check the type is Dict[str, Dict[str, str]]
        if col and col not in psms.columns:
            raise ValueError(f"Column '{col}' not found in PSM data.")
        if not all(isinstance(x, dict) for x in self._psms[col]):
            raise ValueError(f"Column '{col}' must contain dictionaries.")

    if metadata_column:
        check_metadata_column(metadata_column)

    def check_rescoring_features(features: Dict[str, List[str]]):
        for key, cols in features.items():
            for col in cols:
                if col not in psms.columns:
                    raise ValueError(
                        f"Column '{col}' not found in PSM data for feature '{key}'."
                    )

    check_rescoring_features(rescoring_features)

    # check if the number of decoy psms is not 0
    if len(self.decoy_psms) == 0:
        logger.error("No decoy PSMs found. Please check the decoy prefix.")
        raise ValueError("No decoy PSMs found.")

    logger.info("PsmContainer initialized with %d PSM entries.", len(self._psms))
    if self.ms_data_file_column:
        logger.info(
            "PSMs originated from %d MS data file(s).",
            len(self._psms[ms_data_file_column].unique()),
        )
    logger.info("target psms: %d", len(self.target_psms))
    logger.info("decoy psms: %d", len(self.decoy_psms))
    logger.info("unique peptides: %d", len(np.unique(self.peptides)))
    logger.info("rescoring features: %s", rescoring_features)

psms property

Get a copy of the PSM DataFrame to prevent external modification.

Returns:

Type Description
DataFrame

A copy of the PSM DataFrame.

target_psms property

Get a DataFrame containing only target PSMs.

Returns:

Type Description
DataFrame

DataFrame with only target PSMs (label = True).

decoy_psms property

Get a DataFrame containing only decoy PSMs.

Returns:

Type Description
DataFrame

DataFrame with only decoy PSMs (label = False).

columns property

Get the column names of the PSM DataFrame.

Returns:

Type Description
list of str

List of column names.

feature_columns property

Get a list of all feature columns in the PSM DataFrame.

Returns:

Type Description
list of str

List of feature column names.

feature_sources property

Get a list of all feature sources in the PSM DataFrame.

Returns:

Type Description
list of str

List of feature source names.

peptides property

Get the peptide sequences from the PSM data.

Returns:

Type Description
list of str

List of peptide sequences.

ms_data_files property

Get the MS data files from the PSM data.

Returns:

Type Description
list of str

List of MS data file names.

scan_ids property

Get the scan numbers from the PSM data.

Returns:

Type Description
list of int

List of scan numbers.

charges property

Get the charge states from the PSM data.

Returns:

Type Description
list of int

List of charge states.

metadata property

Get the metadata from the PSM data.

Returns:

Type Description
Series

Series containing metadata for each PSM.

spectrum_ids property

Get the spectrum identifiers from the PSM data.

Returns:

Type Description
list of str

List of spectrum identifiers.

identifier_columns property

Get the columns that uniquely identify each PSM.

Returns:

Type Description
list of str

List of identifier column names.

__len__()

Get the number of PSMs in the container.

Returns:

Type Description
int

Number of PSMs.

Source code in optimhc/psm_container.py
def __len__(self) -> int:
    """
    Get the number of PSMs in the container.

    Returns
    -------
    int
        Number of PSMs.
    """
    return len(self._psms)

copy()

Return a deep copy of the PsmContainer object.

Returns:

Type Description
PsmContainer

A deep copy of the current PsmContainer.

Source code in optimhc/psm_container.py
def copy(self) -> "PsmContainer":
    """
    Return a deep copy of the PsmContainer object.

    Returns
    -------
    PsmContainer
        A deep copy of the current PsmContainer.
    """
    import copy

    return copy.deepcopy(self)

__repr__()

Return a string representation of the PsmContainer.

Returns:

Type Description
str

String summary of the PsmContainer.

Source code in optimhc/psm_container.py
def __repr__(self) -> str:
    """
    Return a string representation of the PsmContainer.

    Returns
    -------
    str
        String summary of the PsmContainer.
    """
    return (
        f"PsmContainer with {len(self)} PSMs\n"
        f"\t - Target PSMs: {len(self.target_psms)}\n"
        f"\t - Decoy PSMs: {len(self.decoy_psms)}\n"
        f"\t - Unique Peptides: {len(np.unique(self.peptides))}\n"
        f"\t - Unique Spectra: {len(self._psms[self.spectrum_column].unique())}\n"
        f"\t - Rescoring Features: {self.rescoring_features}\n"
    )

drop_features(features)

Drop specified features from the PSM DataFrame.

Parameters:

Name Type Description Default
features list of str

List of feature column names to drop.

required

Raises:

Type Description
ValueError

If any of the features do not exist in the DataFrame.

Source code in optimhc/psm_container.py
def drop_features(self, features: List[str]) -> None:
    """
    Drop specified features from the PSM DataFrame.

    Parameters
    ----------
    features : list of str
        List of feature column names to drop.

    Raises
    ------
    ValueError
        If any of the features do not exist in the DataFrame.
    """
    missing_features = [f for f in features if f not in self._psms.columns]
    if missing_features:
        raise ValueError(f"Features not found in PSM data: {missing_features}")

    self._psms.drop(columns=features, inplace=True)
    # Create a list of sources to update
    sources_to_update = []
    for source, cols in self.rescoring_features.items():
        self.rescoring_features[source] = [col for col in cols if col not in features]
        if not self.rescoring_features[source]:
            sources_to_update.append(source)

    logger.info(
        f"Sources to be removed: {sources_to_update}. Because all the features are removed."
    )
    # Remove sources with no features left
    for source in sources_to_update:
        del self.rescoring_features[source]

drop_source(source)

Drop all features associated with a specific source from the PSM DataFrame.

Parameters:

Name Type Description Default
source str

Name of the source to drop.

required

Raises:

Type Description
ValueError

If the source does not exist in the rescoring features.

Source code in optimhc/psm_container.py
def drop_source(self, source: str) -> None:
    """
    Drop all features associated with a specific source from the PSM DataFrame.

    Parameters
    ----------
    source : str
        Name of the source to drop.

    Raises
    ------
    ValueError
        If the source does not exist in the rescoring features.
    """
    if source not in self.rescoring_features:
        raise ValueError(f"Source '{source}' not found in rescoring features.")
    self.drop_features(self.rescoring_features[source])

add_metadata(metadata_df, psms_key, metadata_key, source)

Merge new metadata into the PSM DataFrame based on specified columns. Metadata from the specified source is stored as a nested dictionary inside the metadata column.

Parameters:

Name Type Description Default
metadata_df DataFrame

DataFrame containing new metadata to add.

required
psms_key str or list of str

Column name(s) in the PSM data to merge on.

required
metadata_key str or list of str

Column name(s) in the metadata data to merge on.

required
source str

Name of the source of the new metadata.

required
Source code in optimhc/psm_container.py
def add_metadata(
    self,
    metadata_df: pd.DataFrame,
    psms_key: Union[str, List[str]],
    metadata_key: Union[str, List[str]],
    source,
) -> None:
    """
    Merge new metadata into the PSM DataFrame based on specified columns.
    Metadata from the specified source is stored as a nested dictionary inside the metadata column.

    Parameters
    ----------
    metadata_df : pd.DataFrame
        DataFrame containing new metadata to add.
    psms_key : str or list of str
        Column name(s) in the PSM data to merge on.
    metadata_key : str or list of str
        Column name(s) in the metadata data to merge on.
    source : str
        Name of the source of the new metadata.
    """
    if self.metadata_column is None:
        logger.info("No existing metadata column. Creating new metadata column.")
        self.metadata_column = "metadata"
        self._psms["metadata"] = [{} for _ in range(len(self._psms))]

    metadata_cols = [col for col in metadata_df.columns if col not in metadata_key]
    merged_df = self.psms.merge(
        metadata_df, left_on=psms_key, right_on=metadata_key, how="left"
    )
    if source in self._psms["metadata"]:
        logger.warning(f"{source} already exists in metadata. Overwriting.")
    for col in metadata_cols:
        merged_df["metadata"] = merged_df.apply(
            lambda row: {
                **row["metadata"],
                source: (
                    {col: row[col]}
                    if source not in row["metadata"]
                    else {**row["metadata"][source], col: row[col]}
                ),
            },
            axis=1,
        )

    self._psms["metadata"] = merged_df["metadata"]

get_top_hits(n=1)

Get the top n hits based on the hit rank column. If the hit rank column is not specified, returns the original PSMs.

Parameters:

Name Type Description Default
n int

The number of top hits to return. Default is 1.

1

Returns:

Type Description
PsmContainer

A new PsmContainer object containing the top n hits.

Source code in optimhc/psm_container.py
def get_top_hits(self, n: int = 1):
    """
    Get the top n hits based on the hit rank column.
    If the hit rank column is not specified, returns the original PSMs.

    Parameters
    ----------
    n : int, optional
        The number of top hits to return. Default is 1.

    Returns
    -------
    PsmContainer
        A new PsmContainer object containing the top n hits.
    """
    if self.hit_rank_column is None:
        logger.warning("Rank column not specified. Return the original PSMs.")
        return self.copy()

    psms = self.copy()
    psms._psms = psms._psms[psms._psms[self.hit_rank_column] <= n]
    return psms

add_features(features_df, psms_key, feature_key, source, suffix=None)

Merge new features into the PSM DataFrame based on specified columns.

This method performs a left join between the PSM data and feature data, ensuring that all PSMs are preserved while adding new features. It handles column name conflicts through optional suffixing and maintains feature source tracking.

Parameters:

Name Type Description Default
features_df DataFrame

DataFrame containing new features to add.

required
psms_key str or list of str

Column name(s) in the PSM data to merge on.

required
feature_key str or list of str

Column name(s) in the features data to merge on.

required
source str

Name of the source of the new features (e.g., 'deeplc', 'netmhc').

required
suffix str

Suffix to add to the new columns if there's a name conflict. Required when new feature columns have the same names as existing columns. For example, if adding features from different sources (e.g., 'score' from DeepLC and NetMHC), use suffixes like '_deeplc' or '_netmhc' to distinguish them.

None

Returns:

Type Description
None

Raises:

Type Description
ValueError

If duplicate columns exist without suffix. If merging features changes the number of PSMs.

Notes

The method follows these steps: 1. Validates input and prepares merge keys 2. Checks for column name conflicts 3. Manages feature source: if the source already exists, it will be overwritten 4. Performs left join merge 5. Verifies data integrity

Suffix Usage

The suffix parameter is used to handle column name conflicts: - When adding features from different sources that might have the same column names - When you want to keep both the original and new features with the same name - When you need to track the source of features in the column names

If suffix is not provided and there are duplicate column names: - The method will raise a ValueError - You must either provide a suffix or rename the columns before adding

Examples:

>>> container = PsmContainer(...)
>>> # Adding features without suffix (no conflicts)
>>> features_df1 = pd.DataFrame({
...     'scan': [1, 2, 3],
...     'feature1': [0.1, 0.2, 0.3],
...     'feature2': [0.4, 0.5, 0.6]
... })
>>> container.add_features(
...     features_df1,
...     psms_key='scan',
...     feature_key='scan',
...     source='source1'
... )
>>> # Adding features with suffix (handling conflicts)
>>> features_df2 = pd.DataFrame({
...     'scan': [1, 2, 3],
...     'score': [0.8, 0.9, 0.7],  # This would conflict with existing 'score'
...     'feature3': [0.7, 0.8, 0.9]
... })
>>> container.add_features(
...     features_df2,
...     psms_key='scan',
...     feature_key='scan',
...     source='source2',
...     suffix='_new'  # 'score' becomes 'score_new'
... )
Source code in optimhc/psm_container.py
def add_features(
    self,
    features_df: pd.DataFrame,
    psms_key: Union[str, List[str]],
    feature_key: Union[str, List[str]],
    source: str,
    suffix: Optional[str] = None,
) -> None:
    """Merge new features into the PSM DataFrame based on specified columns.

    This method performs a left join between the PSM data and feature data,
    ensuring that all PSMs are preserved while adding new features. It handles
    column name conflicts through optional suffixing and maintains feature source
    tracking.

    Parameters
    ----------
    features_df : pd.DataFrame
        DataFrame containing new features to add.
    psms_key : str or list of str
        Column name(s) in the PSM data to merge on.
    feature_key : str or list of str
        Column name(s) in the features data to merge on.
    source : str
        Name of the source of the new features (e.g., 'deeplc', 'netmhc').
    suffix : str, optional
        Suffix to add to the new columns if there's a name conflict.
        Required when new feature columns have the same names as existing columns.
        For example, if adding features from different sources (e.g., 'score' from
        DeepLC and NetMHC), use suffixes like '_deeplc' or '_netmhc' to distinguish them.

    Returns
    -------
    None

    Raises
    ------
    ValueError
        If duplicate columns exist without suffix.
        If merging features changes the number of PSMs.

    Notes
    -----
    The method follows these steps:
    1. Validates input and prepares merge keys
    2. Checks for column name conflicts
    3. Manages feature source: if the source already exists, it will be overwritten
    4. Performs left join merge
    5. Verifies data integrity

    Suffix Usage
    -----------
    The suffix parameter is used to handle column name conflicts:
    - When adding features from different sources that might have the same column names
    - When you want to keep both the original and new features with the same name
    - When you need to track the source of features in the column names

    If suffix is not provided and there are duplicate column names:
    - The method will raise a ValueError
    - You must either provide a suffix or rename the columns before adding

    Examples
    --------
    >>> container = PsmContainer(...)
    >>> # Adding features without suffix (no conflicts)
    >>> features_df1 = pd.DataFrame({
    ...     'scan': [1, 2, 3],
    ...     'feature1': [0.1, 0.2, 0.3],
    ...     'feature2': [0.4, 0.5, 0.6]
    ... })
    >>> container.add_features(
    ...     features_df1,
    ...     psms_key='scan',
    ...     feature_key='scan',
    ...     source='source1'
    ... )
    >>> # Adding features with suffix (handling conflicts)
    >>> features_df2 = pd.DataFrame({
    ...     'scan': [1, 2, 3],
    ...     'score': [0.8, 0.9, 0.7],  # This would conflict with existing 'score'
    ...     'feature3': [0.7, 0.8, 0.9]
    ... })
    >>> container.add_features(
    ...     features_df2,
    ...     psms_key='scan',
    ...     feature_key='scan',
    ...     source='source2',
    ...     suffix='_new'  # 'score' becomes 'score_new'
    ... )
    """
    if isinstance(psms_key, str):
        psms_key = [psms_key]

    if isinstance(feature_key, str):
        feature_key = [feature_key]

    new_feature_cols = [col for col in features_df.columns if col not in feature_key]

    for cols in new_feature_cols:
        if cols in self._psms.columns:
            logger.warning(f"Column '{cols}' already exists in PSM data.")
            if suffix is None:
                logger.warning("No suffix provided. Using default suffix ")
                raise ValueError("Duplicate columns exist. No suffix provided.")
            else:
                logger.warning(f"Suffix '{suffix}' provided. Using suffix '{suffix}'.")
    logger.info(f"Adding {len(new_feature_cols)} new features from {source}.")

    if not new_feature_cols:
        logger.warning("No new features to add. Check the feature key and PSMs key.")
        logger.warning(f"Feature key: {feature_key}; PSMs key: {psms_key}")

    if source in self.rescoring_features:
        logger.warning(f"{source} already exists in rescoring features. Overwriting.")
        self.drop_source(source)

    # TODO: reluctant logic
    if suffix is None:
        suffixes = ("", "")
    else:
        suffixes = ("", suffix)

    self.rescoring_features[source] = [col + suffixes[1] for col in new_feature_cols]
    features_df = features_df.rename(
        columns={col: col + suffixes[1] for col in new_feature_cols}
    )
    original_len = len(self._psms)
    # avoid merge the right key to the psms
    self._psms = self._psms.merge(
        features_df, left_on=psms_key, right_on=feature_key, how="left"
    )

    if feature_key != psms_key:
        cols_to_drop = [
            col for col in feature_key if col not in psms_key and col in self._psms.columns
        ]
        if cols_to_drop:
            logger.debug(f"Dropping columns from feature_key not in psms_key: {cols_to_drop}")
            self._psms.drop(columns=cols_to_drop, inplace=True)

    if len(self._psms) != original_len:
        raise ValueError(
            "Merging features resulted in a change in the number of PSMs. Check for duplicate keys."
        )

add_features_by_index(features_df, source, suffix=None)

Merge new features into the PSM DataFrame based on the DataFrame index.

Parameters:

Name Type Description Default
features_df DataFrame

DataFrame containing new features to add.

required
source str

Name of the source of the new features.

required
suffix str

Suffix to add to the new columns if there's a name conflict.

None
Source code in optimhc/psm_container.py
def add_features_by_index(
    self, features_df: pd.DataFrame, source: str, suffix: Optional[str] = None
) -> None:
    """
    Merge new features into the PSM DataFrame based on the DataFrame index.

    Parameters
    ----------
    features_df : pd.DataFrame
        DataFrame containing new features to add.
    source : str
        Name of the source of the new features.
    suffix : str, optional
        Suffix to add to the new columns if there's a name conflict.
    """
    new_feature_cols = [col for col in features_df.columns]
    for col in new_feature_cols:
        if col in self._psms.columns:
            logger.warning(f"Column '{col}' already exists in PSM data.")
            if suffix is None:
                logger.warning("No suffix provided. Using default suffix.")
                raise ValueError("Duplicate columns exist. No suffix provided.")
            else:
                logger.warning(f"Suffix '{suffix}' provided. Using suffix '{suffix}'.")

    logger.info(f"Adding {len(new_feature_cols)} new features from {source} by index.")

    if not new_feature_cols:
        logger.warning("No new features to add.")
        raise ValueError("No new features to add.")

    if source in self.rescoring_features:
        logger.warning(f"{source} already exists in rescoring features. Overwriting.")
        self.drop_source(source)

    if suffix is None:
        suffixes = ("", "")
    else:
        suffixes = ("", suffix)

    self.rescoring_features[source] = [col + suffixes[1] for col in new_feature_cols]
    features_df.rename(
        columns={col: col + suffixes[1] for col in new_feature_cols}, inplace=True
    )
    original_len = len(self._psms)
    self._psms = self._psms.merge(
        features_df,
        left_index=True,
        right_index=True,
        how="left",  # Perform a left join to preserve all original PSM data
    )

    # Ensure that the merge did not change the number of rows in the PSM DataFrame
    if len(self._psms) != original_len:
        raise ValueError(
            "Merging features resulted in a change in the number of PSMs. Check for duplicate indices."
        )

add_results(results_df, psms_key, result_key)

Add results of rescore engine to the PSM DataFrame based on specified columns.

Parameters:

Name Type Description Default
results_df DataFrame

DataFrame containing new results to add.

required
psms_key str or list of str

Column name(s) in the PSM data to merge on.

required
result_key str or list of str

Column name(s) in the results data to merge on.

required
Source code in optimhc/psm_container.py
def add_results(
    self,
    results_df: pd.DataFrame,
    psms_key: Union[str, List[str]],
    result_key: Union[str, List[str]],
) -> None:
    """
    Add results of rescore engine to the PSM DataFrame based on specified columns.

    Parameters
    ----------
    results_df : pd.DataFrame
        DataFrame containing new results to add.
    psms_key : str or list of str
        Column name(s) in the PSM data to merge on.
    result_key : str or list of str
        Column name(s) in the results data to merge on.
    """
    if self.rescore_result_column is not None:
        logger.warning("Rescore result column already exists. Overwriting.")

    if set(self._psms.columns) & set(results_df.columns):
        raise ValueError(
            "Duplicate columns exist. Please rename the columns in the results data."
        )

    self.rescore_result_column = result_key
    self._psms = self._psms.merge(
        results_df,
        left_on=psms_key,
        right_on=result_key,
        how="left",
        validate="one_to_one",
    )
    self._psms.drop(columns=result_key, inplace=True)
    logger.info("Added rescore results to PSM data.")

write_pin(output_file, style='default', source=None)

Write the PSM data to a Percolator PIN file, supporting both generic Percolator and MSBooster-compatible formats. The style parameter is actually used to output a unified pin format file to benchmark the performance of different rescoring methods.

Parameters:

Name Type Description Default
output_file str

Path to the output PIN file.

required
style str

If set to 'msbooster', outputs only the columns required by MSBooster (SpecId, Label, ScanNr, retentiontime, rank, hyperscore or log10_evalue, Peptide, Proteins). If set to 'default', outputs all features specified in rescoring_features, plus required Percolator columns.

'default'
source list of str

List of feature sources to include. If None, includes all sources.

None

Returns:

Type Description
DataFrame

The DataFrame written to the PIN file.

Notes
  • The first three columns are always: SpecID, Label, ScanNr.
  • For 'msbooster' style, the columns are: SpecId, Label, ScanNr, retentiontime, rank, hyperscore or log10_evalue, Peptide, Proteins.
  • If hit_rank_column is not specified, rank is set to 1 for all rows.
  • Either 'hyperscore' or 'expect' must be present in features; for 'expect', the column is written as 'log10_evalue'.
  • The 'log10_evalue' column should contain the base-10 logarithm of the e-value.
  • The 'Peptide' column is formatted with underscores (e.g., _.PEPTIDE._).
  • For standard format, all features from rescoring_features are appended between ScanNr and Peptide columns.
  • The 'Proteins' column is a semicolon-separated list if stored as a list or tuple.
  • Label column is converted to 1 (target) and -1 (decoy), as required by Percolator.

Example output (default style): SpecId Label ScanNr feature1 feature2 ... Peptide Proteins

Example output (msbooster style): SpecId Label ScanNr retentiontime rank hyperscore Peptide Proteins or SpecId Label ScanNr retentiontime rank log10_evalue Peptide Proteins

Raises:

Type Description
ValueError

If required columns are missing for the selected style.

Source code in optimhc/psm_container.py
def write_pin(
    self, output_file: str, style: str = "default", source: List[str] = None
) -> None:
    """
    Write the PSM data to a Percolator PIN file, supporting both generic Percolator and MSBooster-compatible formats.
    The style parameter is actually used to output a unified pin format file to benchmark the performance of different rescoring methods.

    Parameters
    ----------
    output_file : str
        Path to the output PIN file.
    style : str, optional
        If set to 'msbooster', outputs only the columns required by MSBooster (SpecId, Label, ScanNr, retentiontime, rank, hyperscore or log10_evalue, Peptide, Proteins).
        If set to 'default', outputs all features specified in `rescoring_features`, plus required Percolator columns.
    source : list of str, optional
        List of feature sources to include. If None, includes all sources.

    Returns
    -------
    pd.DataFrame
        The DataFrame written to the PIN file.

    Notes
    -----
    - The first three columns are always: SpecID, Label, ScanNr.
    - For 'msbooster' style, the columns are: SpecId, Label, ScanNr, retentiontime, rank, hyperscore or log10_evalue, Peptide, Proteins.
    - If `hit_rank_column` is not specified, rank is set to 1 for all rows.
    - Either 'hyperscore' or 'expect' must be present in features; for 'expect', the column is written as 'log10_evalue'.
    - The 'log10_evalue' column should contain the base-10 logarithm of the e-value.
    - The 'Peptide' column is formatted with underscores (e.g., `_.PEPTIDE._`).
    - For standard format, all features from `rescoring_features` are appended between ScanNr and Peptide columns.
    - The 'Proteins' column is a semicolon-separated list if stored as a list or tuple.
    - Label column is converted to 1 (target) and -1 (decoy), as required by Percolator.

    Example output (default style):
        SpecId	Label	ScanNr	feature1	feature2	...	Peptide	Proteins

    Example output (msbooster style):
        SpecId	Label	ScanNr	retentiontime	rank	hyperscore	Peptide	Proteins
        or
        SpecId	Label	ScanNr	retentiontime	rank	log10_evalue	Peptide	Proteins

    Raises
    ------
    ValueError
        If required columns are missing for the selected style.
    """
    df = self._psms.copy()
    # Check if the label column is str
    # Case1: label column is str
    if df[self.label_column].dtype == "str":
        df["PercolatorLabel"] = df[self.label_column].map({"True": 1, "False": -1})
    # Case2: label column is bool
    elif df[self.label_column].dtype == "bool":
        df["PercolatorLabel"] = df[self.label_column].map({True: 1, False: -1})
    else:
        # try to convert to bool
        logger.warning("Label column is not str or bool. Converting to bool.")
        df["PercolatorLabel"] = df[self.label_column].astype(bool).map({True: 1, False: -1})
    logger.info("Writing PIN file to %s", output_file)
    logger.info("Using style: %s", style)

    feature_cols = []
    if source is None:
        for _, cols in self.rescoring_features.items():
            feature_cols.extend(cols)
    else:
        for s in source:
            if s not in self.rescoring_features:
                raise ValueError(f"Source '{s}' not found in rescoring features.")
            feature_cols.extend(self.rescoring_features[s])

    pin_df = pd.DataFrame()
    pin_df["SpecId"] = df[self.spectrum_column]
    pin_df["Label"] = df["PercolatorLabel"]
    pin_df["ScanNr"] = df[self.scan_column]

    if style == "msbooster":
        if self.retention_time_column:
            pin_df["retentiontime"] = df[self.retention_time_column]
        else:
            raise ValueError("Retention time column is required for msbooster style.")

        pin_df["rank"] = df[self.hit_rank_column].astype(int) if self.hit_rank_column else 1
        if "hyperscore" in self.feature_columns:
            pin_df["hyperscore"] = df["hyperscore"]
        elif "expect" in self.feature_columns:
            pin_df["log10_evalue"] = df["expect"]
        else:
            raise ValueError(
                "Either 'hyperscore' or 'expect' column is required for msbooster style."
            )

        # Add other features and jump the hyperscore or expect column
        for col in feature_cols:
            if col not in [
                "hyperscore",
                "expect",
                self.hit_rank_column,
                self.retention_time_column,
            ]:
                pin_df[col] = df[col]

        # PEPTIDE -> _.PEPTIDE._
        # Add _. at the front and ._ at the end of the peptide column
        pin_df["Peptide"] = df[self.peptide_column].apply(
            lambda x: f"_.{x}._" if isinstance(x, str) else x
        )

    elif style == "default":
        for col in feature_cols:
            pin_df[col] = df[col]
        pin_df["Peptide"] = df[self.peptide_column]
    else:
        raise ValueError(f"Unknown style: {style}. Use 'msbooster' or 'default'.")

    pin_df["Proteins"] = df[self.protein_column].apply(
        lambda x: ";".join(x) if isinstance(x, (list, tuple)) else x
    )
    pin_df = self._convert_float_to_int(pin_df)
    pin_df.to_csv(output_file, sep="\t", index=False)
    logger.info("PIN file written to %s", output_file)
    return pin_df

save_or_show_plot(save_path, logger, tight_layout=True)

Source code in optimhc/visualization/save_or_show_plot.py
def save_or_show_plot(save_path, logger, tight_layout=True):
    if tight_layout:
        plt.tight_layout()
    if save_path:
        os.makedirs(os.path.dirname(save_path), exist_ok=True)
        plt.savefig(save_path, bbox_inches="tight")
        logger.info(f"Plot saved to {save_path}")
    else:
        plt.show()
    plt.close("all")

plot_feature_importance(models, rescoring_features, save_path=None, sort=False, error=False, **kwargs)

Unified function to plot average feature importance across multiple models.

This function supports: - Linear models (e.g., Linear SVR) which provide an 'estimator' attribute with a 'coef_'. The absolute value of the coefficients is used for importance, and hatch patterns are applied to differentiate between positive and negative coefficients. - XGBoost models which provide a 'feature_importances_' attribute. Since these values are always positive, no hatch patterns are applied.

Parameters:

Name Type Description Default
models list

A list of model objects. For linear models, each model should have an 'estimator' with 'coef_'. For XGBoost models, each model should have a 'feature_importances_' attribute.

required
rescoring_features dict

A dictionary where keys are sources and values are lists of features.

required
save_path str

If provided, saves the plot to the specified path.

None
sort bool

If True, sorts the features by their importance in descending order. Default is False.

False
error bool

If True, adds error bars to the plot. Default is False.

False
**kwargs dict

Additional plotting parameters: - 'figsize' : tuple, default (15, 10) Figure size in inches (width, height). - 'dpi' : int, default 300 Resolution in dots per inch. - 'palette' : str, default 'crest' Seaborn color palette name. Options include 'crest', 'flare', 'mako', 'rocket', 'tab10', 'husl', 'Set2', etc.

{}
Notes

The function automatically detects the model type based on the presence of the corresponding attribute. For linear models, it uses hatch patterns to differentiate between positive and negative coefficients. For XGBoost models, it uses solid bars since the importances are always positive.

The color palette is automatically scaled to match the number of feature sources, ensuring consistent colors between the bars and legend.

Examples:

>>> # Use default crest palette
>>> plot_feature_importance(models, rescoring_features, save_path='importance.png')
>>> # Use a different palette
>>> plot_feature_importance(models, rescoring_features, palette='flare', sort=True, error=True)
Source code in optimhc/visualization/plot_features.py
def plot_feature_importance(
    models, rescoring_features, save_path=None, sort=False, error=False, **kwargs
):
    """
    Unified function to plot average feature importance across multiple models.

    This function supports:
      - Linear models (e.g., Linear SVR) which provide an 'estimator' attribute with a 'coef_'.
        The absolute value of the coefficients is used for importance, and hatch patterns are applied
        to differentiate between positive and negative coefficients.
      - XGBoost models which provide a 'feature_importances_' attribute. Since these values are
        always positive, no hatch patterns are applied.

    Parameters
    ----------
    models : list
        A list of model objects.
        For linear models, each model should have an 'estimator' with 'coef_'.
        For XGBoost models, each model should have a 'feature_importances_' attribute.
    rescoring_features : dict
        A dictionary where keys are sources and values are lists of features.
    save_path : str, optional
        If provided, saves the plot to the specified path.
    sort : bool, optional
        If True, sorts the features by their importance in descending order.
        Default is False.
    error : bool, optional
        If True, adds error bars to the plot. Default is False.
    **kwargs : dict
        Additional plotting parameters:
        - 'figsize' : tuple, default (15, 10)
            Figure size in inches (width, height).
        - 'dpi' : int, default 300
            Resolution in dots per inch.
        - 'palette' : str, default 'crest'
            Seaborn color palette name. Options include 'crest', 'flare', 'mako',
            'rocket', 'tab10', 'husl', 'Set2', etc.

    Notes
    -----
    The function automatically detects the model type based on the presence of the corresponding attribute.
    For linear models, it uses hatch patterns to differentiate between positive and negative coefficients.
    For XGBoost models, it uses solid bars since the importances are always positive.

    The color palette is automatically scaled to match the number of feature sources, ensuring
    consistent colors between the bars and legend.

    Examples
    --------
    >>> # Use default crest palette
    >>> plot_feature_importance(models, rescoring_features, save_path='importance.png')

    >>> # Use a different palette
    >>> plot_feature_importance(models, rescoring_features, palette='flare', sort=True, error=True)
    """
    # Determine the model type based on the first model in the list.
    if hasattr(models[0].estimator, "coef_"):
        model_type = "linear"
    elif hasattr(models[0].estimator, "feature_importances_"):
        model_type = "xgb"
    else:
        raise ValueError(
            "Model type not recognized. Model must have 'estimator.coef_' for linear models or "
            "'estimator.feature_importances_' for XGBoost models."
        )

    if model_type == "linear":
        feature_importances = []
        for model in models:
            coefficients = model.estimator.coef_
            feature_importances.append(np.abs(coefficients).mean(axis=0))
            logger.debug(f"Model coefficients shape: {coefficients.shape}")

        average_feature_importance = np.mean(feature_importances, axis=0)
        std_feature_importance = np.std(feature_importances, axis=0)
        feature_signs = np.mean([model.estimator.coef_.mean(axis=0) for model in models], axis=0)

    elif model_type == "xgb":
        feature_importances = []
        for model in models:
            # Use the XGBoost feature importances directly as they are always positive
            imp = model.estimator.feature_importances_
            feature_importances.append(imp)
            logger.debug(f"Model feature importances shape: {imp.shape}")

        average_feature_importance = np.mean(feature_importances, axis=0)
        std_feature_importance = np.std(feature_importances, axis=0)
        feature_signs = np.ones_like(average_feature_importance)

    logger.debug(f"Total rescoring features: {len(sum(rescoring_features.values(), []))}")
    logger.debug(f"Average feature importance length: {len(average_feature_importance)}")
    logger.debug(f"Features: {sum(rescoring_features.values(), [])}")

    # Extract plotting parameters
    figsize = kwargs.get("figsize", (15, 10))
    dpi = kwargs.get("dpi", 300)
    palette_name = kwargs.get("palette", "Set2")

    all_features = []
    all_importances = []
    all_errors = []
    all_colors = []
    all_hatches = []  # Hatch patterns will be applied only for linear models.

    n_sources = len(rescoring_features)
    colors = sns.color_palette(palette_name, n_colors=n_sources)
    source_colors = dict(zip(rescoring_features.keys(), colors))

    for source, features in rescoring_features.items():
        color = source_colors[source]
        indices = [
            i for i, name in enumerate(sum(rescoring_features.values(), [])) if name in features
        ]
        source_importances = average_feature_importance[indices]
        source_std = std_feature_importance[indices]

        if model_type == "linear":
            source_signs = feature_signs[indices]

        if sort:
            sorted_indices = np.argsort(-source_importances)
        else:
            sorted_indices = np.arange(len(features))

        sorted_features = [features[i] for i in sorted_indices]
        sorted_importances = source_importances[sorted_indices]
        sorted_std = source_std[sorted_indices]

        all_features.extend(sorted_features)
        all_importances.extend(sorted_importances)
        all_errors.extend(sorted_std)
        all_colors.extend([color] * len(sorted_features))

        if model_type == "linear":
            # For linear models, use hatch patterns to differentiate positive and negative coefficients.
            # An empty hatch ('') for positive and '\\' for negative coefficients.
            sorted_signs = source_signs[sorted_indices]
            all_hatches.extend(["" if sign >= 0 else "\\\\" for sign in sorted_signs])
        else:
            all_hatches.extend([""] * len(sorted_features))

    fig, ax = plt.subplots(figsize=figsize, dpi=dpi)
    if error:
        bars = ax.barh(all_features, all_importances, xerr=all_errors, color=all_colors, capsize=5)
    else:
        bars = ax.barh(all_features, all_importances, color=all_colors)

    if model_type == "linear":
        for bar, hatch in zip(bars, all_hatches):
            bar.set_hatch(hatch)
        legend_hatches = [
            Patch(facecolor="white", edgecolor="black", hatch="", label="Positive"),
            Patch(facecolor="white", edgecolor="black", hatch="\\\\", label="Negative"),
        ]
        legend_colors = [
            Patch(facecolor=source_colors[source], edgecolor="black", label=source)
            for source in rescoring_features.keys()
        ]
        ax.legend(handles=legend_hatches + legend_colors, loc="best")
    else:
        legend_colors = [
            Patch(facecolor=source_colors[source], edgecolor="black", label=source)
            for source in rescoring_features.keys()
        ]
        ax.legend(handles=legend_colors, loc="best")

    ax.set_xlabel("Average Feature Importance")
    ax.set_ylabel("Feature")

    save_or_show_plot(save_path, logger)

visualize_feature_correlation(psms, save_path=None, **kwargs)

Visualize the correlation between features in a DataFrame using a scatter plot heatmap.

Parameters:

Name Type Description Default
psms PsmContainer

A PsmContainer object containing the features to visualize.

required
save_path str

The file path to save the plot. If not provided, the plot is displayed.

None
**kwargs dict

Additional plotting parameters such as figsize, dpi, height, etc.

{}
Source code in optimhc/visualization/plot_features.py
def visualize_feature_correlation(psms: PsmContainer, save_path=None, **kwargs):
    """
    Visualize the correlation between features in a DataFrame using a scatter plot heatmap.

    Parameters
    ----------
    psms : PsmContainer
        A PsmContainer object containing the features to visualize.
    save_path : str, optional
        The file path to save the plot. If not provided, the plot is displayed.
    **kwargs : dict
        Additional plotting parameters such as `figsize`, `dpi`, `height`, etc.
    """
    rescoring_features = [item for sublist in psms.rescoring_features.values() for item in sublist]
    n_features = len(rescoring_features)

    default_height = max(8, min(20, 8 + n_features * 0.15))
    height = kwargs.get("height", default_height)
    dpi = kwargs.get("dpi", 300)

    corr = psms.psms[rescoring_features].corr()
    corr_mat = corr.stack().reset_index(name="correlation")

    g = sns.relplot(
        data=corr_mat,
        x="level_0",
        y="level_1",
        hue="correlation",
        size="correlation",
        palette="vlag",
        hue_norm=(-1, 1),
        edgecolor=".7",
        height=height,
        sizes=(50, 250),
        size_norm=(-0.2, 0.8),
        **{k: v for k, v in kwargs.items() if k not in ["height", "dpi", "figsize"]},
    )

    g.set(xlabel="", ylabel="", aspect="equal")
    g.despine(left=True, bottom=True)
    g.ax.margins(0.02)

    for label in g.ax.get_xticklabels():
        label.set_rotation(90)

    if n_features > 30:
        fontsize = max(6, 10 - n_features * 0.05)
        g.ax.tick_params(labelsize=fontsize)

    g.figure.suptitle("Feature Correlation Matrix", y=1.01, fontsize=14)
    plt.tight_layout()
    g.figure.dpi = dpi

    save_or_show_plot(save_path, logger)

Target/Decoy Distribution

plot_tdc_distribution

logger = logging.getLogger(__name__) module-attribute

PsmContainer(psms, label_column, scan_column, spectrum_column, ms_data_file_column, peptide_column, protein_column, rescoring_features, hit_rank_column=None, charge_column=None, retention_time_column=None, calculated_mass_column=None, metadata_column=None)

A container for managing peptide-spectrum matches (PSMs) in immunopeptidomics rescoring pipelines.

Parameters:

Name Type Description Default
psms DataFrame

DataFrame containing the PSM data.

required
label_column str

Column containing the label (True for target, False for decoy).

required
scan_column str

Column containing the scan number.

required
spectrum_column str

Column containing the spectrum identifier.

required
ms_data_file_column str

Column containing the MS data file that the PSM originated from.

required
peptide_column str

Column containing the peptide sequence.

required
protein_column str

Column containing the protein accessions.

required
rescoring_features dict of str to list of str

Dictionary of feature columns for rescoring.

required
hit_rank_column str

Column containing the hit rank.

None
charge_column str

Column containing the charge state.

None
retention_time_column str

Column containing the retention time.

None
calculated_mass_column str

Column containing the calculated mass.

None
metadata_column str

Column containing metadata.

None

Attributes:

Name Type Description
psms DataFrame

Copy of the DataFrame containing the PSM data.

target_psms DataFrame

DataFrame containing only target PSMs (label = True).

decoy_psms DataFrame

DataFrame containing only decoy PSMs (label = False).

peptides list of str

List containing all peptides from the PSM data.

columns list of str

List of column names in the PSM DataFrame.

rescoring_features dict of str to list of str

Dictionary of rescoring feature columns in the PSM DataFrame.

Source code in optimhc/psm_container.py
def __init__(
    self,
    psms: pd.DataFrame,
    label_column: str,
    scan_column: str,
    spectrum_column: str,
    ms_data_file_column: str,
    peptide_column: str,
    protein_column: str,
    rescoring_features: Dict[str, List[str]],
    hit_rank_column: Optional[str] = None,
    charge_column: Optional[str] = None,
    retention_time_column: Optional[str] = None,
    calculated_mass_column: Optional[str] = None,
    metadata_column: Optional[str] = None,
):
    self._psms = psms.copy()
    self._psms.reset_index(drop=True, inplace=True)
    self.label_column = label_column
    self.scan_column = scan_column
    self.spectrum_column = spectrum_column
    self.ms_data_file_column = ms_data_file_column
    self.peptide_column = peptide_column
    self.protein_column = protein_column
    self.hit_rank_column = hit_rank_column
    self.retention_time_column = retention_time_column
    self.metadata_column = metadata_column
    self.rescoring_features = rescoring_features
    self.charge_column = charge_column
    self.calculated_mass_column = calculated_mass_column
    # rescore result column
    self.rescore_result_column = None

    # check if the columns are in the dataframe
    def check_column(col):
        if col and col not in psms.columns:
            raise ValueError(f"Column '{col}' not found in PSM data.")

    check_column(label_column)
    check_column(scan_column)
    check_column(spectrum_column)
    check_column(ms_data_file_column)
    check_column(peptide_column)
    check_column(protein_column)
    check_column(hit_rank_column)
    check_column(retention_time_column)
    check_column(charge_column)
    check_column(calculated_mass_column)

    # ensure the label column is boolean
    if psms[label_column].dtype != "bool":
        raise ValueError(f"Column '{label_column}' must be boolean.")

    if psms[label_column].nunique() == 1 and psms[label_column].iloc[0]:
        raise ValueError("All PSMs are labeled as target. No decoy PSMs found.")
    elif psms[label_column].nunique() == 1 and not psms[label_column].iloc[0]:
        raise ValueError("All PSMs are labeled as decoy. No target PSMs found.")

    def check_metadata_column(col):
        # check the type is Dict[str, Dict[str, str]]
        if col and col not in psms.columns:
            raise ValueError(f"Column '{col}' not found in PSM data.")
        if not all(isinstance(x, dict) for x in self._psms[col]):
            raise ValueError(f"Column '{col}' must contain dictionaries.")

    if metadata_column:
        check_metadata_column(metadata_column)

    def check_rescoring_features(features: Dict[str, List[str]]):
        for key, cols in features.items():
            for col in cols:
                if col not in psms.columns:
                    raise ValueError(
                        f"Column '{col}' not found in PSM data for feature '{key}'."
                    )

    check_rescoring_features(rescoring_features)

    # check if the number of decoy psms is not 0
    if len(self.decoy_psms) == 0:
        logger.error("No decoy PSMs found. Please check the decoy prefix.")
        raise ValueError("No decoy PSMs found.")

    logger.info("PsmContainer initialized with %d PSM entries.", len(self._psms))
    if self.ms_data_file_column:
        logger.info(
            "PSMs originated from %d MS data file(s).",
            len(self._psms[ms_data_file_column].unique()),
        )
    logger.info("target psms: %d", len(self.target_psms))
    logger.info("decoy psms: %d", len(self.decoy_psms))
    logger.info("unique peptides: %d", len(np.unique(self.peptides)))
    logger.info("rescoring features: %s", rescoring_features)

psms property

Get a copy of the PSM DataFrame to prevent external modification.

Returns:

Type Description
DataFrame

A copy of the PSM DataFrame.

target_psms property

Get a DataFrame containing only target PSMs.

Returns:

Type Description
DataFrame

DataFrame with only target PSMs (label = True).

decoy_psms property

Get a DataFrame containing only decoy PSMs.

Returns:

Type Description
DataFrame

DataFrame with only decoy PSMs (label = False).

columns property

Get the column names of the PSM DataFrame.

Returns:

Type Description
list of str

List of column names.

feature_columns property

Get a list of all feature columns in the PSM DataFrame.

Returns:

Type Description
list of str

List of feature column names.

feature_sources property

Get a list of all feature sources in the PSM DataFrame.

Returns:

Type Description
list of str

List of feature source names.

peptides property

Get the peptide sequences from the PSM data.

Returns:

Type Description
list of str

List of peptide sequences.

ms_data_files property

Get the MS data files from the PSM data.

Returns:

Type Description
list of str

List of MS data file names.

scan_ids property

Get the scan numbers from the PSM data.

Returns:

Type Description
list of int

List of scan numbers.

charges property

Get the charge states from the PSM data.

Returns:

Type Description
list of int

List of charge states.

metadata property

Get the metadata from the PSM data.

Returns:

Type Description
Series

Series containing metadata for each PSM.

spectrum_ids property

Get the spectrum identifiers from the PSM data.

Returns:

Type Description
list of str

List of spectrum identifiers.

identifier_columns property

Get the columns that uniquely identify each PSM.

Returns:

Type Description
list of str

List of identifier column names.

__len__()

Get the number of PSMs in the container.

Returns:

Type Description
int

Number of PSMs.

Source code in optimhc/psm_container.py
def __len__(self) -> int:
    """
    Get the number of PSMs in the container.

    Returns
    -------
    int
        Number of PSMs.
    """
    return len(self._psms)

copy()

Return a deep copy of the PsmContainer object.

Returns:

Type Description
PsmContainer

A deep copy of the current PsmContainer.

Source code in optimhc/psm_container.py
def copy(self) -> "PsmContainer":
    """
    Return a deep copy of the PsmContainer object.

    Returns
    -------
    PsmContainer
        A deep copy of the current PsmContainer.
    """
    import copy

    return copy.deepcopy(self)

__repr__()

Return a string representation of the PsmContainer.

Returns:

Type Description
str

String summary of the PsmContainer.

Source code in optimhc/psm_container.py
def __repr__(self) -> str:
    """
    Return a string representation of the PsmContainer.

    Returns
    -------
    str
        String summary of the PsmContainer.
    """
    return (
        f"PsmContainer with {len(self)} PSMs\n"
        f"\t - Target PSMs: {len(self.target_psms)}\n"
        f"\t - Decoy PSMs: {len(self.decoy_psms)}\n"
        f"\t - Unique Peptides: {len(np.unique(self.peptides))}\n"
        f"\t - Unique Spectra: {len(self._psms[self.spectrum_column].unique())}\n"
        f"\t - Rescoring Features: {self.rescoring_features}\n"
    )

drop_features(features)

Drop specified features from the PSM DataFrame.

Parameters:

Name Type Description Default
features list of str

List of feature column names to drop.

required

Raises:

Type Description
ValueError

If any of the features do not exist in the DataFrame.

Source code in optimhc/psm_container.py
def drop_features(self, features: List[str]) -> None:
    """
    Drop specified features from the PSM DataFrame.

    Parameters
    ----------
    features : list of str
        List of feature column names to drop.

    Raises
    ------
    ValueError
        If any of the features do not exist in the DataFrame.
    """
    missing_features = [f for f in features if f not in self._psms.columns]
    if missing_features:
        raise ValueError(f"Features not found in PSM data: {missing_features}")

    self._psms.drop(columns=features, inplace=True)
    # Create a list of sources to update
    sources_to_update = []
    for source, cols in self.rescoring_features.items():
        self.rescoring_features[source] = [col for col in cols if col not in features]
        if not self.rescoring_features[source]:
            sources_to_update.append(source)

    logger.info(
        f"Sources to be removed: {sources_to_update}. Because all the features are removed."
    )
    # Remove sources with no features left
    for source in sources_to_update:
        del self.rescoring_features[source]

drop_source(source)

Drop all features associated with a specific source from the PSM DataFrame.

Parameters:

Name Type Description Default
source str

Name of the source to drop.

required

Raises:

Type Description
ValueError

If the source does not exist in the rescoring features.

Source code in optimhc/psm_container.py
def drop_source(self, source: str) -> None:
    """
    Drop all features associated with a specific source from the PSM DataFrame.

    Parameters
    ----------
    source : str
        Name of the source to drop.

    Raises
    ------
    ValueError
        If the source does not exist in the rescoring features.
    """
    if source not in self.rescoring_features:
        raise ValueError(f"Source '{source}' not found in rescoring features.")
    self.drop_features(self.rescoring_features[source])

add_metadata(metadata_df, psms_key, metadata_key, source)

Merge new metadata into the PSM DataFrame based on specified columns. Metadata from the specified source is stored as a nested dictionary inside the metadata column.

Parameters:

Name Type Description Default
metadata_df DataFrame

DataFrame containing new metadata to add.

required
psms_key str or list of str

Column name(s) in the PSM data to merge on.

required
metadata_key str or list of str

Column name(s) in the metadata data to merge on.

required
source str

Name of the source of the new metadata.

required
Source code in optimhc/psm_container.py
def add_metadata(
    self,
    metadata_df: pd.DataFrame,
    psms_key: Union[str, List[str]],
    metadata_key: Union[str, List[str]],
    source,
) -> None:
    """
    Merge new metadata into the PSM DataFrame based on specified columns.
    Metadata from the specified source is stored as a nested dictionary inside the metadata column.

    Parameters
    ----------
    metadata_df : pd.DataFrame
        DataFrame containing new metadata to add.
    psms_key : str or list of str
        Column name(s) in the PSM data to merge on.
    metadata_key : str or list of str
        Column name(s) in the metadata data to merge on.
    source : str
        Name of the source of the new metadata.
    """
    if self.metadata_column is None:
        logger.info("No existing metadata column. Creating new metadata column.")
        self.metadata_column = "metadata"
        self._psms["metadata"] = [{} for _ in range(len(self._psms))]

    metadata_cols = [col for col in metadata_df.columns if col not in metadata_key]
    merged_df = self.psms.merge(
        metadata_df, left_on=psms_key, right_on=metadata_key, how="left"
    )
    if source in self._psms["metadata"]:
        logger.warning(f"{source} already exists in metadata. Overwriting.")
    for col in metadata_cols:
        merged_df["metadata"] = merged_df.apply(
            lambda row: {
                **row["metadata"],
                source: (
                    {col: row[col]}
                    if source not in row["metadata"]
                    else {**row["metadata"][source], col: row[col]}
                ),
            },
            axis=1,
        )

    self._psms["metadata"] = merged_df["metadata"]

get_top_hits(n=1)

Get the top n hits based on the hit rank column. If the hit rank column is not specified, returns the original PSMs.

Parameters:

Name Type Description Default
n int

The number of top hits to return. Default is 1.

1

Returns:

Type Description
PsmContainer

A new PsmContainer object containing the top n hits.

Source code in optimhc/psm_container.py
def get_top_hits(self, n: int = 1):
    """
    Get the top n hits based on the hit rank column.
    If the hit rank column is not specified, returns the original PSMs.

    Parameters
    ----------
    n : int, optional
        The number of top hits to return. Default is 1.

    Returns
    -------
    PsmContainer
        A new PsmContainer object containing the top n hits.
    """
    if self.hit_rank_column is None:
        logger.warning("Rank column not specified. Return the original PSMs.")
        return self.copy()

    psms = self.copy()
    psms._psms = psms._psms[psms._psms[self.hit_rank_column] <= n]
    return psms

add_features(features_df, psms_key, feature_key, source, suffix=None)

Merge new features into the PSM DataFrame based on specified columns.

This method performs a left join between the PSM data and feature data, ensuring that all PSMs are preserved while adding new features. It handles column name conflicts through optional suffixing and maintains feature source tracking.

Parameters:

Name Type Description Default
features_df DataFrame

DataFrame containing new features to add.

required
psms_key str or list of str

Column name(s) in the PSM data to merge on.

required
feature_key str or list of str

Column name(s) in the features data to merge on.

required
source str

Name of the source of the new features (e.g., 'deeplc', 'netmhc').

required
suffix str

Suffix to add to the new columns if there's a name conflict. Required when new feature columns have the same names as existing columns. For example, if adding features from different sources (e.g., 'score' from DeepLC and NetMHC), use suffixes like '_deeplc' or '_netmhc' to distinguish them.

None

Returns:

Type Description
None

Raises:

Type Description
ValueError

If duplicate columns exist without suffix. If merging features changes the number of PSMs.

Notes

The method follows these steps: 1. Validates input and prepares merge keys 2. Checks for column name conflicts 3. Manages feature source: if the source already exists, it will be overwritten 4. Performs left join merge 5. Verifies data integrity

Suffix Usage

The suffix parameter is used to handle column name conflicts: - When adding features from different sources that might have the same column names - When you want to keep both the original and new features with the same name - When you need to track the source of features in the column names

If suffix is not provided and there are duplicate column names: - The method will raise a ValueError - You must either provide a suffix or rename the columns before adding

Examples:

>>> container = PsmContainer(...)
>>> # Adding features without suffix (no conflicts)
>>> features_df1 = pd.DataFrame({
...     'scan': [1, 2, 3],
...     'feature1': [0.1, 0.2, 0.3],
...     'feature2': [0.4, 0.5, 0.6]
... })
>>> container.add_features(
...     features_df1,
...     psms_key='scan',
...     feature_key='scan',
...     source='source1'
... )
>>> # Adding features with suffix (handling conflicts)
>>> features_df2 = pd.DataFrame({
...     'scan': [1, 2, 3],
...     'score': [0.8, 0.9, 0.7],  # This would conflict with existing 'score'
...     'feature3': [0.7, 0.8, 0.9]
... })
>>> container.add_features(
...     features_df2,
...     psms_key='scan',
...     feature_key='scan',
...     source='source2',
...     suffix='_new'  # 'score' becomes 'score_new'
... )
Source code in optimhc/psm_container.py
def add_features(
    self,
    features_df: pd.DataFrame,
    psms_key: Union[str, List[str]],
    feature_key: Union[str, List[str]],
    source: str,
    suffix: Optional[str] = None,
) -> None:
    """Merge new features into the PSM DataFrame based on specified columns.

    This method performs a left join between the PSM data and feature data,
    ensuring that all PSMs are preserved while adding new features. It handles
    column name conflicts through optional suffixing and maintains feature source
    tracking.

    Parameters
    ----------
    features_df : pd.DataFrame
        DataFrame containing new features to add.
    psms_key : str or list of str
        Column name(s) in the PSM data to merge on.
    feature_key : str or list of str
        Column name(s) in the features data to merge on.
    source : str
        Name of the source of the new features (e.g., 'deeplc', 'netmhc').
    suffix : str, optional
        Suffix to add to the new columns if there's a name conflict.
        Required when new feature columns have the same names as existing columns.
        For example, if adding features from different sources (e.g., 'score' from
        DeepLC and NetMHC), use suffixes like '_deeplc' or '_netmhc' to distinguish them.

    Returns
    -------
    None

    Raises
    ------
    ValueError
        If duplicate columns exist without suffix.
        If merging features changes the number of PSMs.

    Notes
    -----
    The method follows these steps:
    1. Validates input and prepares merge keys
    2. Checks for column name conflicts
    3. Manages feature source: if the source already exists, it will be overwritten
    4. Performs left join merge
    5. Verifies data integrity

    Suffix Usage
    -----------
    The suffix parameter is used to handle column name conflicts:
    - When adding features from different sources that might have the same column names
    - When you want to keep both the original and new features with the same name
    - When you need to track the source of features in the column names

    If suffix is not provided and there are duplicate column names:
    - The method will raise a ValueError
    - You must either provide a suffix or rename the columns before adding

    Examples
    --------
    >>> container = PsmContainer(...)
    >>> # Adding features without suffix (no conflicts)
    >>> features_df1 = pd.DataFrame({
    ...     'scan': [1, 2, 3],
    ...     'feature1': [0.1, 0.2, 0.3],
    ...     'feature2': [0.4, 0.5, 0.6]
    ... })
    >>> container.add_features(
    ...     features_df1,
    ...     psms_key='scan',
    ...     feature_key='scan',
    ...     source='source1'
    ... )
    >>> # Adding features with suffix (handling conflicts)
    >>> features_df2 = pd.DataFrame({
    ...     'scan': [1, 2, 3],
    ...     'score': [0.8, 0.9, 0.7],  # This would conflict with existing 'score'
    ...     'feature3': [0.7, 0.8, 0.9]
    ... })
    >>> container.add_features(
    ...     features_df2,
    ...     psms_key='scan',
    ...     feature_key='scan',
    ...     source='source2',
    ...     suffix='_new'  # 'score' becomes 'score_new'
    ... )
    """
    if isinstance(psms_key, str):
        psms_key = [psms_key]

    if isinstance(feature_key, str):
        feature_key = [feature_key]

    new_feature_cols = [col for col in features_df.columns if col not in feature_key]

    for cols in new_feature_cols:
        if cols in self._psms.columns:
            logger.warning(f"Column '{cols}' already exists in PSM data.")
            if suffix is None:
                logger.warning("No suffix provided. Using default suffix ")
                raise ValueError("Duplicate columns exist. No suffix provided.")
            else:
                logger.warning(f"Suffix '{suffix}' provided. Using suffix '{suffix}'.")
    logger.info(f"Adding {len(new_feature_cols)} new features from {source}.")

    if not new_feature_cols:
        logger.warning("No new features to add. Check the feature key and PSMs key.")
        logger.warning(f"Feature key: {feature_key}; PSMs key: {psms_key}")

    if source in self.rescoring_features:
        logger.warning(f"{source} already exists in rescoring features. Overwriting.")
        self.drop_source(source)

    # TODO: reluctant logic
    if suffix is None:
        suffixes = ("", "")
    else:
        suffixes = ("", suffix)

    self.rescoring_features[source] = [col + suffixes[1] for col in new_feature_cols]
    features_df = features_df.rename(
        columns={col: col + suffixes[1] for col in new_feature_cols}
    )
    original_len = len(self._psms)
    # avoid merge the right key to the psms
    self._psms = self._psms.merge(
        features_df, left_on=psms_key, right_on=feature_key, how="left"
    )

    if feature_key != psms_key:
        cols_to_drop = [
            col for col in feature_key if col not in psms_key and col in self._psms.columns
        ]
        if cols_to_drop:
            logger.debug(f"Dropping columns from feature_key not in psms_key: {cols_to_drop}")
            self._psms.drop(columns=cols_to_drop, inplace=True)

    if len(self._psms) != original_len:
        raise ValueError(
            "Merging features resulted in a change in the number of PSMs. Check for duplicate keys."
        )

add_features_by_index(features_df, source, suffix=None)

Merge new features into the PSM DataFrame based on the DataFrame index.

Parameters:

Name Type Description Default
features_df DataFrame

DataFrame containing new features to add.

required
source str

Name of the source of the new features.

required
suffix str

Suffix to add to the new columns if there's a name conflict.

None
Source code in optimhc/psm_container.py
def add_features_by_index(
    self, features_df: pd.DataFrame, source: str, suffix: Optional[str] = None
) -> None:
    """
    Merge new features into the PSM DataFrame based on the DataFrame index.

    Parameters
    ----------
    features_df : pd.DataFrame
        DataFrame containing new features to add.
    source : str
        Name of the source of the new features.
    suffix : str, optional
        Suffix to add to the new columns if there's a name conflict.
    """
    new_feature_cols = [col for col in features_df.columns]
    for col in new_feature_cols:
        if col in self._psms.columns:
            logger.warning(f"Column '{col}' already exists in PSM data.")
            if suffix is None:
                logger.warning("No suffix provided. Using default suffix.")
                raise ValueError("Duplicate columns exist. No suffix provided.")
            else:
                logger.warning(f"Suffix '{suffix}' provided. Using suffix '{suffix}'.")

    logger.info(f"Adding {len(new_feature_cols)} new features from {source} by index.")

    if not new_feature_cols:
        logger.warning("No new features to add.")
        raise ValueError("No new features to add.")

    if source in self.rescoring_features:
        logger.warning(f"{source} already exists in rescoring features. Overwriting.")
        self.drop_source(source)

    if suffix is None:
        suffixes = ("", "")
    else:
        suffixes = ("", suffix)

    self.rescoring_features[source] = [col + suffixes[1] for col in new_feature_cols]
    features_df.rename(
        columns={col: col + suffixes[1] for col in new_feature_cols}, inplace=True
    )
    original_len = len(self._psms)
    self._psms = self._psms.merge(
        features_df,
        left_index=True,
        right_index=True,
        how="left",  # Perform a left join to preserve all original PSM data
    )

    # Ensure that the merge did not change the number of rows in the PSM DataFrame
    if len(self._psms) != original_len:
        raise ValueError(
            "Merging features resulted in a change in the number of PSMs. Check for duplicate indices."
        )

add_results(results_df, psms_key, result_key)

Add results of rescore engine to the PSM DataFrame based on specified columns.

Parameters:

Name Type Description Default
results_df DataFrame

DataFrame containing new results to add.

required
psms_key str or list of str

Column name(s) in the PSM data to merge on.

required
result_key str or list of str

Column name(s) in the results data to merge on.

required
Source code in optimhc/psm_container.py
def add_results(
    self,
    results_df: pd.DataFrame,
    psms_key: Union[str, List[str]],
    result_key: Union[str, List[str]],
) -> None:
    """
    Add results of rescore engine to the PSM DataFrame based on specified columns.

    Parameters
    ----------
    results_df : pd.DataFrame
        DataFrame containing new results to add.
    psms_key : str or list of str
        Column name(s) in the PSM data to merge on.
    result_key : str or list of str
        Column name(s) in the results data to merge on.
    """
    if self.rescore_result_column is not None:
        logger.warning("Rescore result column already exists. Overwriting.")

    if set(self._psms.columns) & set(results_df.columns):
        raise ValueError(
            "Duplicate columns exist. Please rename the columns in the results data."
        )

    self.rescore_result_column = result_key
    self._psms = self._psms.merge(
        results_df,
        left_on=psms_key,
        right_on=result_key,
        how="left",
        validate="one_to_one",
    )
    self._psms.drop(columns=result_key, inplace=True)
    logger.info("Added rescore results to PSM data.")

write_pin(output_file, style='default', source=None)

Write the PSM data to a Percolator PIN file, supporting both generic Percolator and MSBooster-compatible formats. The style parameter is actually used to output a unified pin format file to benchmark the performance of different rescoring methods.

Parameters:

Name Type Description Default
output_file str

Path to the output PIN file.

required
style str

If set to 'msbooster', outputs only the columns required by MSBooster (SpecId, Label, ScanNr, retentiontime, rank, hyperscore or log10_evalue, Peptide, Proteins). If set to 'default', outputs all features specified in rescoring_features, plus required Percolator columns.

'default'
source list of str

List of feature sources to include. If None, includes all sources.

None

Returns:

Type Description
DataFrame

The DataFrame written to the PIN file.

Notes
  • The first three columns are always: SpecID, Label, ScanNr.
  • For 'msbooster' style, the columns are: SpecId, Label, ScanNr, retentiontime, rank, hyperscore or log10_evalue, Peptide, Proteins.
  • If hit_rank_column is not specified, rank is set to 1 for all rows.
  • Either 'hyperscore' or 'expect' must be present in features; for 'expect', the column is written as 'log10_evalue'.
  • The 'log10_evalue' column should contain the base-10 logarithm of the e-value.
  • The 'Peptide' column is formatted with underscores (e.g., _.PEPTIDE._).
  • For standard format, all features from rescoring_features are appended between ScanNr and Peptide columns.
  • The 'Proteins' column is a semicolon-separated list if stored as a list or tuple.
  • Label column is converted to 1 (target) and -1 (decoy), as required by Percolator.

Example output (default style): SpecId Label ScanNr feature1 feature2 ... Peptide Proteins

Example output (msbooster style): SpecId Label ScanNr retentiontime rank hyperscore Peptide Proteins or SpecId Label ScanNr retentiontime rank log10_evalue Peptide Proteins

Raises:

Type Description
ValueError

If required columns are missing for the selected style.

Source code in optimhc/psm_container.py
def write_pin(
    self, output_file: str, style: str = "default", source: List[str] = None
) -> None:
    """
    Write the PSM data to a Percolator PIN file, supporting both generic Percolator and MSBooster-compatible formats.
    The style parameter is actually used to output a unified pin format file to benchmark the performance of different rescoring methods.

    Parameters
    ----------
    output_file : str
        Path to the output PIN file.
    style : str, optional
        If set to 'msbooster', outputs only the columns required by MSBooster (SpecId, Label, ScanNr, retentiontime, rank, hyperscore or log10_evalue, Peptide, Proteins).
        If set to 'default', outputs all features specified in `rescoring_features`, plus required Percolator columns.
    source : list of str, optional
        List of feature sources to include. If None, includes all sources.

    Returns
    -------
    pd.DataFrame
        The DataFrame written to the PIN file.

    Notes
    -----
    - The first three columns are always: SpecID, Label, ScanNr.
    - For 'msbooster' style, the columns are: SpecId, Label, ScanNr, retentiontime, rank, hyperscore or log10_evalue, Peptide, Proteins.
    - If `hit_rank_column` is not specified, rank is set to 1 for all rows.
    - Either 'hyperscore' or 'expect' must be present in features; for 'expect', the column is written as 'log10_evalue'.
    - The 'log10_evalue' column should contain the base-10 logarithm of the e-value.
    - The 'Peptide' column is formatted with underscores (e.g., `_.PEPTIDE._`).
    - For standard format, all features from `rescoring_features` are appended between ScanNr and Peptide columns.
    - The 'Proteins' column is a semicolon-separated list if stored as a list or tuple.
    - Label column is converted to 1 (target) and -1 (decoy), as required by Percolator.

    Example output (default style):
        SpecId	Label	ScanNr	feature1	feature2	...	Peptide	Proteins

    Example output (msbooster style):
        SpecId	Label	ScanNr	retentiontime	rank	hyperscore	Peptide	Proteins
        or
        SpecId	Label	ScanNr	retentiontime	rank	log10_evalue	Peptide	Proteins

    Raises
    ------
    ValueError
        If required columns are missing for the selected style.
    """
    df = self._psms.copy()
    # Check if the label column is str
    # Case1: label column is str
    if df[self.label_column].dtype == "str":
        df["PercolatorLabel"] = df[self.label_column].map({"True": 1, "False": -1})
    # Case2: label column is bool
    elif df[self.label_column].dtype == "bool":
        df["PercolatorLabel"] = df[self.label_column].map({True: 1, False: -1})
    else:
        # try to convert to bool
        logger.warning("Label column is not str or bool. Converting to bool.")
        df["PercolatorLabel"] = df[self.label_column].astype(bool).map({True: 1, False: -1})
    logger.info("Writing PIN file to %s", output_file)
    logger.info("Using style: %s", style)

    feature_cols = []
    if source is None:
        for _, cols in self.rescoring_features.items():
            feature_cols.extend(cols)
    else:
        for s in source:
            if s not in self.rescoring_features:
                raise ValueError(f"Source '{s}' not found in rescoring features.")
            feature_cols.extend(self.rescoring_features[s])

    pin_df = pd.DataFrame()
    pin_df["SpecId"] = df[self.spectrum_column]
    pin_df["Label"] = df["PercolatorLabel"]
    pin_df["ScanNr"] = df[self.scan_column]

    if style == "msbooster":
        if self.retention_time_column:
            pin_df["retentiontime"] = df[self.retention_time_column]
        else:
            raise ValueError("Retention time column is required for msbooster style.")

        pin_df["rank"] = df[self.hit_rank_column].astype(int) if self.hit_rank_column else 1
        if "hyperscore" in self.feature_columns:
            pin_df["hyperscore"] = df["hyperscore"]
        elif "expect" in self.feature_columns:
            pin_df["log10_evalue"] = df["expect"]
        else:
            raise ValueError(
                "Either 'hyperscore' or 'expect' column is required for msbooster style."
            )

        # Add other features and jump the hyperscore or expect column
        for col in feature_cols:
            if col not in [
                "hyperscore",
                "expect",
                self.hit_rank_column,
                self.retention_time_column,
            ]:
                pin_df[col] = df[col]

        # PEPTIDE -> _.PEPTIDE._
        # Add _. at the front and ._ at the end of the peptide column
        pin_df["Peptide"] = df[self.peptide_column].apply(
            lambda x: f"_.{x}._" if isinstance(x, str) else x
        )

    elif style == "default":
        for col in feature_cols:
            pin_df[col] = df[col]
        pin_df["Peptide"] = df[self.peptide_column]
    else:
        raise ValueError(f"Unknown style: {style}. Use 'msbooster' or 'default'.")

    pin_df["Proteins"] = df[self.protein_column].apply(
        lambda x: ";".join(x) if isinstance(x, (list, tuple)) else x
    )
    pin_df = self._convert_float_to_int(pin_df)
    pin_df.to_csv(output_file, sep="\t", index=False)
    logger.info("PIN file written to %s", output_file)
    return pin_df

save_or_show_plot(save_path, logger, tight_layout=True)

Source code in optimhc/visualization/save_or_show_plot.py
def save_or_show_plot(save_path, logger, tight_layout=True):
    if tight_layout:
        plt.tight_layout()
    if save_path:
        os.makedirs(os.path.dirname(save_path), exist_ok=True)
        plt.savefig(save_path, bbox_inches="tight")
        logger.info(f"Plot saved to {save_path}")
    else:
        plt.show()
    plt.close("all")

visualize_target_decoy_features(psms, num_cols=5, save_path=None, **kwargs)

Visualize the distribution of features in a DataFrame using kernel density estimation plots.

Parameters:

Name Type Description Default
psms PsmContainer

A PsmContainer object containing the features to visualize.

required
num_cols int

The number of columns in the plot grid. Default is 5.

5
save_path str

The file path to save the plot. If not provided, the plot is displayed.

None
**kwargs dict

Additional plotting parameters such as figsize and dpi, etc.

{}
Notes

This function: 1. Extracts rescoring features from the PsmContainer 2. Filters out features with only one unique value 3. Creates a grid of plots showing the distribution of each feature 4. Separates target and decoy PSMs in each plot 5. Uses kernel density estimation to show the distribution shape

Source code in optimhc/visualization/plot_tdc_distribution.py
def visualize_target_decoy_features(psms: PsmContainer, num_cols=5, save_path=None, **kwargs):
    """
    Visualize the distribution of features in a DataFrame using kernel density estimation plots.

    Parameters
    ----------
    psms : PsmContainer
        A PsmContainer object containing the features to visualize.
    num_cols : int, optional
        The number of columns in the plot grid. Default is 5.
    save_path : str, optional
        The file path to save the plot. If not provided, the plot is displayed.
    **kwargs : dict
        Additional plotting parameters such as `figsize` and `dpi`, etc.

    Notes
    -----
    This function:
    1. Extracts rescoring features from the PsmContainer
    2. Filters out features with only one unique value
    3. Creates a grid of plots showing the distribution of each feature
    4. Separates target and decoy PSMs in each plot
    5. Uses kernel density estimation to show the distribution shape
    """
    rescoring_features = [
        item
        for sublist in psms.rescoring_features.values()
        for item in sublist
        if item != psms.hit_rank_column
    ]

    # drop features that only have one value
    rescoring_features = [
        feature for feature in rescoring_features if len(psms.psms[feature].unique()) > 1
    ]

    num_features = len(rescoring_features)
    num_rows = (num_features + num_cols - 1) // num_cols

    figsize = kwargs.get("figsize", (15, num_rows * 15 / num_cols))
    dpi = kwargs.get("dpi", 300)

    fig, axes = plt.subplots(num_rows, num_cols, figsize=figsize, dpi=dpi)
    axes = axes.flatten()

    psms_top_hits = psms.psms[psms.psms[psms.hit_rank_column] == 1].copy()
    num_true_hits = len(psms_top_hits[psms_top_hits[psms.label_column]])
    num_decoys = len(psms_top_hits[~psms_top_hits[psms.label_column]])
    logger.debug(f"Number of true hits: {num_true_hits}")
    logger.debug(f"Number of decoys: {num_decoys}")
    psms_top_hits[psms.label_column] = psms_top_hits[psms.label_column].map(
        {True: "Target", False: "Decoy"}
    )

    for i, feature in enumerate(rescoring_features):
        try:
            ax = axes[i]
            sns.histplot(
                data=psms_top_hits,
                x=feature,
                hue=psms.label_column,
                ax=ax,
                bins="auto",
                common_bins=True,
                multiple="dodge",
                fill=True,
                alpha=0.3,
                stat="frequency",
                kde=True,
                linewidth=0,
            )
            ax.set_title(feature)
            ax.set_xlabel("")
            ax.set_ylabel("")

        except Exception as e:
            logger.error(f"Error plotting feature {feature}: {e}")
            ax.set_visible(False)

    for j in range(i + 1, len(axes)):
        fig.delaxes(axes[j])

    save_or_show_plot(save_path, logger)