diff --git a/alphabase/constants/const_files/pg_reader.yaml b/alphabase/constants/const_files/pg_reader.yaml index 9416c21f..072215a9 100644 --- a/alphabase/constants/const_files/pg_reader.yaml +++ b/alphabase/constants/const_files/pg_reader.yaml @@ -73,6 +73,23 @@ maxquant: "lfq": "^LFQ intensity(?!\\s[LHM]\\s).+$" # LFQ intensity-based quantification "ibaq": "^iBAQ(?!\\s[LHM]\\s).+$" # Intensity-Based Absolute Quantification +# Based on PEAKS +peaks_peptides: + reader_type: "peaks_peptides" + column_mapping: + "peptides": "Peptide" + "proteins": "proteins" # this column is added through preprocessing + "genes": "genes" # this column is added through preprocessing + measurement_regex: + "lfq": "^sample_\\d+$" # PEAKS columns renamed from "Area Sample 1" to "sample_1" during preprocessing + +peaks_proteins: + reader_type: "peaks_proteins" + column_mapping: + "proteins": "proteins" # this column is added through preprocessing + "genes": "genes" # this column is added through preprocessing + measurement_regex: + "lfq": "^sample_\\d+$" # PEAKS columns renamed from "Sample 1 Area" to "sample_1" during preprocessing # Based on Spectronaut 20.0 Run Pivot Report (Page 80/81) # https://biognosys.com/content/uploads/2025/06/Spectronaut-20-Manual.pdf diff --git a/alphabase/pg_reader/__init__.py b/alphabase/pg_reader/__init__.py index 99535176..17d8f8a5 100644 --- a/alphabase/pg_reader/__init__.py +++ b/alphabase/pg_reader/__init__.py @@ -5,6 +5,7 @@ from .fragpipe_pg_reader import FragPipePGReader from .maxquant_pg_reader import MaxQuantPGReader from .mztab_pg_reader import MZTabPGReader +from .peaks_pg_reader import PeaksPeptidesReader, PeaksPGReader from .pg_reader import pg_reader_provider from .spectronaut_reader import SpectronautPGReader @@ -18,4 +19,6 @@ "SpectronautPGReader", "FragPipePGReader", "MZTabPGReader", + "PeaksPGReader", + "PeaksPeptidesReader", ] diff --git a/alphabase/pg_reader/keys.py b/alphabase/pg_reader/keys.py index ec0581b3..ed565d5e 100644 --- a/alphabase/pg_reader/keys.py +++ b/alphabase/pg_reader/keys.py @@ -11,6 +11,11 @@ class PGCols(metaclass=ConstantsClass): PROTEINS Uniprot names of proteins in the respective protein group. Individual entries are separated by a semicolon in the unified output. + PEPTIDES + Peptide sequence without modifications. This is specified in the PG-reader + context since conceptually, proteins and peptides are just different levels of + aggregating the fundamental unit of measurement in bottom-up proteomics, + which are precursors. UNIPROT_IDS Uniprot IDs of all proteins in the respective protein group. Individual entries are separated by a semicolon in the unified output. @@ -44,6 +49,7 @@ class PGCols(metaclass=ConstantsClass): # Minimal columns PROTEINS = "proteins" + PEPTIDES = "peptides" UNIPROT_IDS = "uniprot_ids" ENSEMBL_IDS = "ensembl_ids" GENES = "genes" diff --git a/alphabase/pg_reader/peaks_pg_reader.py b/alphabase/pg_reader/peaks_pg_reader.py new file mode 100644 index 00000000..3c0bcb28 --- /dev/null +++ b/alphabase/pg_reader/peaks_pg_reader.py @@ -0,0 +1,234 @@ +"""PEAKS protein group and peptided wide table reader.""" + +from typing import Any, Literal, Optional, Union + +import pandas as pd + +from .keys import PGCols +from .pg_reader import PGReaderBase, pg_reader_provider + +PROTEIN_GENE_SEPARATOR = "|" +ACCESSION_COLUMN = "Accession" +EXPECTED_PAIR_LENGTH = 2 + + +def _assemble_pg_gene_groups( + accession: str, + id_separator: str = PROTEIN_GENE_SEPARATOR, + group_separator: str = ":", +) -> tuple[str, str]: + """Assemble protein and gene groups from PEAKS accession string. + + Parameters + ---------- + accession + Accession string containing protein|gene pairs separated by colons. + Format: "protein1|gene1:protein2|gene2:protein3|gene3" + id_separator + Character separating protein from gene within each pair (default "|"). + group_separator + Character separating multiple protein|gene pairs (default ":"). + + Returns + ------- + tuple[str, str] + Tuple containing (protein_group, gene_group) as semicolon-separated strings. + + Example + ------- + >>> accession = "P1|G1:P2|G2:P3|G3" + >>> _assemble_pg_gene_groups(accession) + ("P1;P2;P3", "G1;G2;G3") + + """ + pg_gene_list = [ + pair.split(id_separator) for pair in accession.split(group_separator) + ] + + proteins = [] + genes = [] + + for pair in pg_gene_list: + if len(pair) >= EXPECTED_PAIR_LENGTH: + proteins.append(pair[0]) + genes.append(pair[1]) + elif len(pair) == 1: + # If no gene provided, just add the protein + proteins.append(pair[0]) + + return ";".join(proteins), ";".join(genes) + + +def _split_peaks_identifiers(df: pd.DataFrame) -> pd.DataFrame: + """Split PEAKS protein group identifiers into separate protein and gene columns. + + The 'Accession' column contains both the protein and gene identifiers separated by + a pipe character. There may be multiple protein|gene pairs separated by a colon (??). + This function assembles all proteins into one protein group and all genes into a gene + group, separated by semicolons. + + Parameters + ---------- + df + DataFrame containing PEAKS protein group report. + + Returns + ------- + pd.DataFrame + Modified copy of the input DataFrame with separate 'protein' and 'gene' columns. + + """ + df_copy = df.copy() + + protein_groups = [] + gene_groups = [] + + for accession in df_copy[ACCESSION_COLUMN]: + protein_group, gene_group = _assemble_pg_gene_groups(accession) + protein_groups.append(protein_group) + gene_groups.append(gene_group) + + df_copy[PGCols.PROTEINS] = protein_groups + df_copy[PGCols.GENES] = gene_groups + + return df_copy + + +def _reformat_observation_indices( + df: pd.DataFrame, + modality: Literal["peptide", "protein"], +) -> pd.DataFrame: + """Parse PEAKS sample column names to standardized format. + + The peptide table contains sample names like Area Sample 1, Area Sample 2, etc. + The protein table contains sample names like Sample 1 Area, Sample 2 Area, etc. + + We parse both to appropriate sample names like sample_1, sample_2, etc. + + Parameters + ---------- + df + DataFrame with PEAKS sample columns. + modality + Type of PEAKS table ("peptide" or "protein") to determine parsing strategy. + + Returns + ------- + pd.DataFrame + DataFrame with standardized column names. + + """ + if modality == "peptide": + # Only rename columns that match "Area Sample X" pattern + def parser(col_name: str) -> str: + if col_name.startswith("Area Sample"): + return col_name.lower().replace("area ", "").replace(" ", "_") + return col_name + elif modality == "protein": + # Only rename columns that match "Sample X Area" pattern + def parser(col_name: str) -> str: + if col_name.startswith("Sample") and col_name.endswith("Area"): + return col_name.lower().replace(" area", "").replace(" ", "_") + return col_name + else: + raise ValueError(f"Unknown modality: {modality}") + + return df.rename(columns=parser) + + +class PeaksPGReader(PGReaderBase): + """Reader for protein group matrices created by PEAKS.""" + + _reader_type = "peaks_proteins" + + def __init__( + self, + *, + column_mapping: Optional[dict[str, Any]] = None, + measurement_regex: Union[str, Literal["lfq"], None] = "lfq", # noqa: PYI051 lfq is a special case and not equivalent to string + ): + """Initialize PEAKS protein group matrix reader. + + Parameters + ---------- + column_mapping + Dictionary mapping alphabase column names (keys) to PEAKS column names (values). + If `None`, uses default mapping from configuration file. + measurement_regex + Pattern to select quantity columns + + - "lfq": LFQ-corrected intensities (_LFQ suffix) + + See class documentation for usage examples and `get_preconfigured_regex()` for available patterns. + + """ + super().__init__( + column_mapping=column_mapping, measurement_regex=measurement_regex + ) + + def _pre_process(self, df: pd.DataFrame) -> pd.DataFrame: + """Preprocess PEAKS protein group report and return modified copy of the dataframe. + + The 'Accession' column contains both the protein and gene identifiers separated by + a pipe character. This method splits these identifiers into a 'protein' and a 'gene' column. + + Additionally, parse the sample names to a better format. + + """ + df_processed = _split_peaks_identifiers(df) + return _reformat_observation_indices( + df_processed, + modality="protein", + ) + + +pg_reader_provider.register_reader("peaks_proteins", reader_class=PeaksPGReader) + + +class PeaksPeptidesReader(PGReaderBase): + """Reader for peptide matrices created by PEAKS.""" + + _reader_type = "peaks_peptides" + + def __init__( + self, + *, + column_mapping: Optional[dict[str, Any]] = None, + measurement_regex: Union[str, Literal["lfq"], None] = "lfq", # noqa: PYI051 lfq is a special case and not equivalent to string + ): + """Initialize PEAKS peptide matrix reader. + + Parameters + ---------- + column_mapping + Dictionary mapping alphabase column names (keys) to PEAKS column names (values). + If `None`, uses default mapping from configuration file. + measurement_regex + Pattern to select quantity columns + + - "lfq": LFQ-corrected intensities (_LFQ suffix) + + See class documentation for usage examples and `get_preconfigured_regex()` for available patterns. + + """ + super().__init__( + column_mapping=column_mapping, measurement_regex=measurement_regex + ) + + def _pre_process(self, df: pd.DataFrame) -> pd.DataFrame: + """Preprocess PEAKS peptide report and return modified copy of the dataframe. + + The 'Accession' column contains both the protein and gene identifiers separated by + a pipe character. This method splits these identifiers into a 'protein' and a 'gene' column. + + Additionally, parse the sample names to a better format. + + """ + df_processed = _split_peaks_identifiers(df) + return _reformat_observation_indices( + df_processed, + modality="peptide", + ) + + +pg_reader_provider.register_reader("peaks_peptides", reader_class=PeaksPeptidesReader) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 03c4243e..8ac3fdcc 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -274,3 +274,44 @@ def example_directlfq_minimal(tmp_path) -> Path: reference = get_local_reference_data(test_case_name=TEST_FILE_NAME) return file_path, reference + + +@pytest.fixture(scope="function") +def example_peaks_proteins_csv(tmp_path) -> tuple[Path, pd.DataFrame]: + """Get and parse PEAKS protein group report for local testing.""" + TEST_FILE_NAME = "pg_peaks_proteins.tsv" + # Note: Accession format is "protein|gene" and can have multiple pairs separated by ":" + TEST_DATA = """Accession,Gene,Sample 1 Area,Sample 2 Area,Sample 3 Area,Sample 4 Area +P12345|GENE1,GENE1,1000000.5,1200000.3,1100000.7,1050000.2 +Q67890|GENE2,GENE2,500000.1,520000.9,510000.4,505000.8 +P11111|GENE3:Q22222|GENE4,GENE3,2000000.0,2100000.5,2050000.2,2020000.1 +R33333|GENE5:S44444|GENE6:T55555|GENE7,GENE5,750000.3,780000.1,760000.9,770000.5 +P99999|GENE8,GENE8,300000.0,0.0,310000.5,305000.2 + """ + file_path = write_test_data( + data=TEST_DATA, directory=tmp_path, test_case_name=TEST_FILE_NAME + ) + reference = get_local_reference_data(test_case_name=TEST_FILE_NAME) + + return file_path, reference + + +@pytest.fixture(scope="function") +def example_peaks_peptides_csv(tmp_path) -> tuple[Path, pd.DataFrame]: + """Get and parse PEAKS peptide report for local testing.""" + TEST_FILE_NAME = "pg_peaks_peptides.tsv" + # Note: Accession format is "protein|gene" and can have multiple pairs separated by ":" + # Column naming differs from protein table: "Area Sample 1" not "Sample 1 Area" + TEST_DATA = """Accession,Peptide,Area Sample 1,Area Sample 2,Area Sample 3,Area Sample 4 +P12345|GENE1,PEPTIDEAAA,50000.5,52000.3,51000.7,50500.2 +Q67890|GENE2,PEPTIDEBBB,25000.1,26000.9,25500.4,25250.8 +P11111|GENE3:Q22222|GENE4,PEPTIDECCC,100000.0,105000.5,102500.2,101000.1 +R33333|GENE5:S44444|GENE6:T55555|GENE7,PEPTIDEDDD,37500.3,39000.1,38000.9,38500.5 +P99999|GENE8,PEPTIDEEEE,15000.0,0.0,15500.5,15250.2 + """ + file_path = write_test_data( + data=TEST_DATA, directory=tmp_path, test_case_name=TEST_FILE_NAME + ) + reference = get_local_reference_data(test_case_name=TEST_FILE_NAME) + + return file_path, reference diff --git a/tests/integration/test_pg_reader_provider.py b/tests/integration/test_pg_reader_provider.py index 5d5d1f7a..16d674b3 100644 --- a/tests/integration/test_pg_reader_provider.py +++ b/tests/integration/test_pg_reader_provider.py @@ -7,6 +7,8 @@ FragPipePGReader, MaxQuantPGReader, MZTabPGReader, + PeaksPeptidesReader, + PeaksPGReader, SpectronautPGReader, pg_reader_provider, ) @@ -66,3 +68,17 @@ def test_reader_provider(self) -> None: reader = pg_reader_provider.get_reader("mztab") assert isinstance(reader, MZTabPGReader) + + +class TestPeaksPGReaderProvider: + def test_reader_provider(self) -> None: + """Test whether reader provider initializes PEAKS protein group reader correctly.""" + reader = pg_reader_provider.get_reader("peaks_proteins") + + assert isinstance(reader, PeaksPGReader) + + def test_peptides_reader_provider(self) -> None: + """Test whether reader provider initializes PEAKS peptides reader correctly.""" + reader = pg_reader_provider.get_reader("peaks_peptides") + + assert isinstance(reader, PeaksPeptidesReader) diff --git a/tests/integration/test_pg_readers.py b/tests/integration/test_pg_readers.py index 4863616a..d2f3e37d 100644 --- a/tests/integration/test_pg_readers.py +++ b/tests/integration/test_pg_readers.py @@ -11,6 +11,8 @@ FragPipePGReader, MaxQuantPGReader, MZTabPGReader, + PeaksPeptidesReader, + PeaksPGReader, SpectronautPGReader, ) from alphabase.pg_reader.keys import PGCols @@ -248,3 +250,131 @@ def test_import_minimal_example(self, example_directlfq_minimal: str) -> None: result_df = reader.import_file(file_path=file_path) pd.testing.assert_frame_equal(result_df, reference) + + +class TestPeaksPGReader: + """Test PEAKS protein group reader""" + + def test_import_peaks_proteins( + self, example_peaks_proteins_csv: tuple[str, pd.DataFrame] + ) -> None: + """Test import of PEAKS protein group file""" + file_path, reference = example_peaks_proteins_csv + + reader = PeaksPGReader() + + result_df = reader.import_file(file_path=file_path) + + pd.testing.assert_frame_equal(result_df, reference) + + def test_protein_gene_splitting( + self, example_peaks_proteins_csv: tuple[str, pd.DataFrame] + ) -> None: + """Test that protein|gene pairs are correctly split and grouped""" + file_path, _ = example_peaks_proteins_csv + + reader = PeaksPGReader() + result_df = reader.import_file(file_path=file_path) + + # Check that index has correct columns + assert result_df.index.names == [PGCols.PROTEINS, PGCols.GENES] + + # Check shape: 5 proteins x 4 samples + assert result_df.shape == (5, 4) + + # Check that multi-protein groups are correctly assembled + assert "P11111;Q22222" in result_df.index.get_level_values(PGCols.PROTEINS) + assert "GENE3;GENE4" in result_df.index.get_level_values(PGCols.GENES) + + # Check triple protein group + assert "R33333;S44444;T55555" in result_df.index.get_level_values( + PGCols.PROTEINS + ) + assert "GENE5;GENE6;GENE7" in result_df.index.get_level_values(PGCols.GENES) + + def test_column_renaming( + self, example_peaks_proteins_csv: tuple[str, pd.DataFrame] + ) -> None: + """Test that column names are standardized from PEAKS format""" + file_path, _ = example_peaks_proteins_csv + + reader = PeaksPGReader() + result_df = reader.import_file(file_path=file_path) + + # Check standardized column names + assert "sample_1" in result_df.columns + assert "sample_2" in result_df.columns + assert "sample_3" in result_df.columns + assert "sample_4" in result_df.columns + + # Ensure original names are gone + assert "Sample 1 Area" not in result_df.columns + assert "Sample 2 Area" not in result_df.columns + + +class TestPeaksPeptidesReader: + """Test PEAKS peptide reader""" + + def test_import_peaks_peptides( + self, example_peaks_peptides_csv: tuple[str, pd.DataFrame] + ) -> None: + """Test import of PEAKS peptide file""" + file_path, reference = example_peaks_peptides_csv + + reader = PeaksPeptidesReader() + + result_df = reader.import_file(file_path=file_path) + + pd.testing.assert_frame_equal(result_df, reference) + + def test_peptide_protein_gene_splitting( + self, example_peaks_peptides_csv: tuple[str, pd.DataFrame] + ) -> None: + """Test that protein|gene pairs are correctly split and grouped in peptide data""" + file_path, _ = example_peaks_peptides_csv + + reader = PeaksPeptidesReader() + result_df = reader.import_file(file_path=file_path) + + # Check that index has correct columns (peptides first per YAML config) + assert result_df.index.names == [ + PGCols.PEPTIDES, + PGCols.PROTEINS, + PGCols.GENES, + ] + + # Check shape: 5 peptides x 4 samples + assert result_df.shape == (5, 4) + + # Check that multi-protein groups are correctly assembled + assert "P11111;Q22222" in result_df.index.get_level_values(PGCols.PROTEINS) + assert "GENE3;GENE4" in result_df.index.get_level_values(PGCols.GENES) + + # Check peptide sequences are present + assert "PEPTIDEAAA" in result_df.index.get_level_values(PGCols.PEPTIDES) + assert "PEPTIDECCC" in result_df.index.get_level_values(PGCols.PEPTIDES) + + # Check triple protein group + assert "R33333;S44444;T55555" in result_df.index.get_level_values( + PGCols.PROTEINS + ) + assert "GENE5;GENE6;GENE7" in result_df.index.get_level_values(PGCols.GENES) + + def test_column_renaming( + self, example_peaks_peptides_csv: tuple[str, pd.DataFrame] + ) -> None: + """Test that column names are standardized from PEAKS peptide format""" + file_path, _ = example_peaks_peptides_csv + + reader = PeaksPeptidesReader() + result_df = reader.import_file(file_path=file_path) + + # Check standardized column names + assert "sample_1" in result_df.columns + assert "sample_2" in result_df.columns + assert "sample_3" in result_df.columns + assert "sample_4" in result_df.columns + + # Ensure original names are gone + assert "Area Sample 1" not in result_df.columns + assert "Area Sample 2" not in result_df.columns