Skip to content

Commit 62cb5f4

Browse files
authored
Merge pull request #156 from Roestlab/feature/lib_export
Feature: Export Library
2 parents 6be5a57 + 0c0d99d commit 62cb5f4

19 files changed

Lines changed: 382 additions & 3 deletions

pyprophet/_config.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -635,6 +635,7 @@ class ExportIOConfig(BaseIOConfig):
635635
- "legacy_split": Split TSV files for each run.
636636
- "parquet": Single Parquet file with merged results.
637637
- "parquet_split": Split Parquet files for each run.
638+
- "library" : .tsv library file
638639
out_type (Literal["tsv", "csv"]): Output file type for exported results.
639640
transition_quantification (bool): Report aggregated transition-level quantification.
640641
max_transition_pep (float): Maximum PEP to retain scored transitions for quantification (requires transition-level scoring).
@@ -653,6 +654,7 @@ class ExportIOConfig(BaseIOConfig):
653654
top_n (int): Number of top intense features to use for summarization
654655
consistent_top (bool): Whether to use same top features across all runs
655656
normalization (Literal["none", "median", "medianmedian", "quantile"]): Normalization method
657+
test: bool = False: Whether to enable test mode with deterministic behavior, test mode will sort libraries by precursor, fragmentType, fragmentSeriesNumber and fragmentCharge
656658
657659
# OSW: Export to parquet
658660
compression_method (Literal["none", "snappy", "gzip", "brotli", "zstd"]): Compression method for parquet files.
@@ -662,10 +664,18 @@ class ExportIOConfig(BaseIOConfig):
662664
663665
# SqMass: Export to parquet
664666
pqp_file (Optional[str]): Path to PQP file for precursor/transition mapping.
667+
668+
# Export to library
669+
rt_calibration (bool): If True, will use emperical RT values as oppose to the original library RT values
670+
im_calibration (bool): If True, will use emperical IM values as oppose to the original library IM values
671+
intensity_calibration (bool): If True, will use emperical intensity values as oppose to the original library intensity values
672+
min_fragments (int): Minimum number of fragments required to include the peak group in the library, only relevant if intensity_calibration is True
673+
keep_decoys (bool): Whether to keep decoy entries in the library, will only keep decoys that pass the thresholds specified
674+
rt_unit (Literal["iRT", "RT"], default = 'iRT') = "iRT": Unit of retention time in the library, only relevant if rt_calibration is True. If "iRT" is selected, the retention times will be scaled to the iRT scale (0-100) in the library
665675
"""
666676

667677
export_format: Literal[
668-
"matrix", "legacy_merged", "legacy_split", "parquet", "parquet_split"
678+
"matrix", "legacy_merged", "legacy_split", "parquet", "parquet_split", "library"
669679
] = "legacy_merged"
670680
out_type: Literal["tsv", "csv"] = "tsv"
671681
transition_quantification: bool = False
@@ -677,6 +687,7 @@ class ExportIOConfig(BaseIOConfig):
677687
max_global_peptide_qvalue: float = 0.01
678688
protein: bool = True
679689
max_global_protein_qvalue: float = 0.01
690+
test: bool = False
680691

681692
# Quantification matrix options
682693
top_n: int = 3
@@ -691,3 +702,11 @@ class ExportIOConfig(BaseIOConfig):
691702

692703
# SqMass: Export to parquet
693704
pqp_file: Optional[str] = None # Path to PQP file for precursor/transition mapping
705+
706+
# Export to library options
707+
rt_calibration: bool = True
708+
im_calibration: bool = True
709+
intensity_calibration: bool = True
710+
min_fragments: int = 4
711+
keep_decoys: bool = False # Whether to keep decoy entries in the library
712+
rt_unit: Literal["iRT", "RT"] = "iRT"

pyprophet/cli/export.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ def export():
3737
pass
3838

3939
export.add_command(export_tsv, name="tsv")
40+
export.add_command(export_library, name='library')
4041
export.add_command(export_matrix, name="matrix")
4142
export.add_command(export_parquet, name="parquet")
4243
export.add_command(export_compound, name="compound")
@@ -347,6 +348,131 @@ def export_matrix(
347348
df = reader.read()
348349
writer.export_quant_matrix(df)
349350

351+
# Export to Library to be used in OpenSWATH
352+
@click.command(name="library", cls=AdvancedHelpCommand)
353+
@click.option(
354+
"--in",
355+
"infile",
356+
required=True,
357+
type=click.Path(exists=True),
358+
help="PyProphet OSW input file.",
359+
)
360+
@click.option(
361+
"--out",
362+
"outfile",
363+
required=True, # need to name the library or else get error in os.path.splittext line 75, in __post_init__in _base.
364+
type=click.Path(exists=False),
365+
help="Output tsv library.",
366+
)
367+
@click.option(
368+
"--max_peakgroup_qvalue",
369+
default=0.01,
370+
show_default=True,
371+
type=float,
372+
help="Filter results to maximum run-specific peak group-level q-value, using values greater than final statistical filtering (in most cases > 0.01), may lead to an overestimation in identification rates. If there are multiple runs with the same precursors, the run with the lowest q value is used",
373+
)
374+
@click.option(
375+
"--max_global_peptide_qvalue",
376+
default=0.01,
377+
show_default=True,
378+
type=float,
379+
help="Filter results to maximum global peptide-level q-value, using values greater than final statistical filtering (in most cases > 0.01), may lead to an overestimation in identification rates."
380+
)
381+
@click.option(
382+
"--max_global_protein_qvalue",
383+
default=0.01,
384+
show_default=True,
385+
type=float,
386+
help="Filter results to maximum global protein-level q-value, using values greater than final statistical filtering (in most cases > 0.01), may lead to an overestimation in identification rates."
387+
)
388+
@click.option(
389+
"--rt_calibration/--no-rt_calibration",
390+
default=True,
391+
show_default=True,
392+
help="Use empirical RT values as oppose to the original library RT values."
393+
)
394+
@click.option(
395+
"--im_calibration/--no-im_calibration",
396+
default=True,
397+
show_default=True,
398+
help="Use empirical IM values as oppose to the original library IM values."
399+
)
400+
@click.option(
401+
"--intensity_calibration/--no-intensity_calibration",
402+
default=True,
403+
show_default=True,
404+
help="Use empirical intensity values as oppose to the original library intensity values."
405+
)
406+
@click.option(
407+
"--min_fragments",
408+
default=4,
409+
show_default=True,
410+
type=int,
411+
help="Minimum number of fragments required to include the peak group in the library, only relevant if intensityCalibration is True."
412+
)
413+
@click.option(
414+
"--keep_decoys/--no-keep_decoys",
415+
default=False,
416+
show_default=True,
417+
type=bool,
418+
help="(Experimental) Whether to keep decoys in the exported library. Default is False, which means decoys are filtered out. Only keeps decoys passing thresholds specified above"
419+
)
420+
@click.option(
421+
"--rt_unit",
422+
default="iRT",
423+
show_default=True,
424+
type=click.Choice(["iRT", "RT"]),
425+
help='Unit of retention time in the library, only relevant if rt_calibration is True. If "iRT" is selected, the retention times will be scaled to the iRT scale (0-100) in the library.',
426+
hidden=True
427+
)
428+
@click.option(
429+
"--test/--no-test",
430+
default=False,
431+
show_default=True,
432+
help="Enable test mode with deterministic behavior, test mode will sort libraries by precursor, fragmentType, fragmentSeriesNumber and fragmentCharge")
433+
@measure_memory_usage_and_time
434+
def export_library(
435+
infile,
436+
outfile,
437+
max_peakgroup_qvalue,
438+
max_global_peptide_qvalue,
439+
max_global_protein_qvalue,
440+
rt_calibration,
441+
im_calibration,
442+
intensity_calibration,
443+
min_fragments,
444+
keep_decoys,
445+
rt_unit,
446+
test
447+
):
448+
"""
449+
Export OSW to tsv library format
450+
"""
451+
config = ExportIOConfig(
452+
infile=infile,
453+
outfile=outfile,
454+
subsample_ratio=1.0, # Not used in export
455+
level="export",
456+
context="export",
457+
export_format="library",
458+
out_type="tsv",
459+
max_rs_peakgroup_qvalue=max_peakgroup_qvalue,
460+
max_global_peptide_qvalue=max_global_peptide_qvalue,
461+
max_global_protein_qvalue=max_global_protein_qvalue,
462+
rt_calibration=rt_calibration,
463+
im_calibration=im_calibration,
464+
intensity_calibration=intensity_calibration,
465+
min_fragments=min_fragments,
466+
keep_decoys=keep_decoys,
467+
rt_unit=rt_unit,
468+
test=test
469+
)
470+
471+
reader = ReaderDispatcher.get_reader(config)
472+
writer = WriterDispatcher.get_writer(config)
473+
474+
df = reader.read()
475+
writer.clean_and_export_library(df)
350476

351477
# Export to Parquet
352478
@click.command(name="parquet", cls=AdvancedHelpCommand)

pyprophet/io/_base.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
import duckdb
4949
import pandas as pd
5050
import polars as pl
51+
import sklearn.preprocessing as preprocessing # For MinMaxScaler
5152
from loguru import logger
5253

5354
from .._base import BaseIOConfig
@@ -619,6 +620,62 @@ def export_results(self, data: pd.DataFrame):
619620
else:
620621
raise ValueError(f"Unsupported export format: {cfg.export_format}")
621622

623+
def clean_and_export_library(self, data: pd.DataFrame) -> pd.DataFrame:
624+
"""
625+
This function cleans the original dataframe and exports the library
626+
627+
Args:
628+
data: Input DataFrame with library data
629+
630+
"""
631+
cfg = self.config
632+
633+
# For precursors found in more than one run, select the run with the smallest q value
634+
# If q values are the same, select the first run
635+
data = data.sort_values(by=['Q_Value', 'Intensity', 'RunId']).groupby("TransitionId").head(1)
636+
assert len(data['TransitionId'].drop_duplicates()) == len(data), "After filtering by Q_Value Intensity and RunId, duplicate transition IDs found."
637+
638+
# Remove Annotation Column if all NAN
639+
if data['Annotation'].isnull().all() or data['Annotation'].eq("NA").all():
640+
logger.debug("Annotation column is empty, so computing it manually.")
641+
data.drop(columns=['Annotation'], inplace=True)
642+
data['Annotation'] = data['FragmentType'] + data['FragmentSeriesNumber'].astype(str) + '^' + data['FragmentCharge'].astype(str)
643+
644+
if cfg.rt_calibration and cfg.rt_unit == "iRT":
645+
data['NormalizedRetentionTime'] = preprocessing.MinMaxScaler().fit_transform(data[['NormalizedRetentionTime']]) * 100
646+
if cfg.intensity_calibration:
647+
data['LibraryIntensity'] = (
648+
data['LibraryIntensity'] /
649+
data.groupby('Precursor')['LibraryIntensity'].transform('max') *
650+
10000)
651+
logger.debug("Removing {} rows with zero intensity.".format(len(data[data['LibraryIntensity'] <= 0])))
652+
# Remove rows with zero intensity
653+
data = data[data['LibraryIntensity'] > 0]
654+
655+
## Print Library statistics
656+
logger.info(f"Library Contains {len(data['Precursor'].drop_duplicates())} Precursors")
657+
658+
logger.info(f"Precursor Fragment Distribution (Before Filtering)")
659+
num_frags_per_prec = data[['Precursor', 'TransitionId']].groupby("Precursor").count().reset_index(names='Precursor').groupby('TransitionId').count()
660+
for frag, count in num_frags_per_prec.iterrows():
661+
logger.info(f"There are {count['Precursor']} precursors with {frag} fragment(s)")
662+
663+
logger.info(f"Filter library to precursors containing {cfg.min_fragments} or more fragments")
664+
ids_to_keep = data[['Precursor', 'Annotation']].groupby('Precursor').count()
665+
ids_to_keep = ids_to_keep[ ids_to_keep['Annotation'] >= cfg.min_fragments ].index
666+
data = data[ data['Precursor'].isin(ids_to_keep) ]
667+
668+
logger.info(f"After filtering, library contains {len(data['Precursor'].drop_duplicates())} Precursors")
669+
if cfg.keep_decoys:
670+
logger.info("Of Which {} are decoys".format(len(data[data['Decoy'] == 1]['Precursor'].drop_duplicates())))
671+
672+
data.drop(columns=['TransitionId', 'Q_Value', 'RunId', 'Intensity'], inplace=True)
673+
if cfg.test:
674+
data = data.sort_values(by=['Precursor', 'FragmentType', 'FragmentSeriesNumber', 'FragmentCharge', 'ProductMz'])
675+
676+
logger.info("Exporting library to file.")
677+
data.to_csv(cfg.outfile, sep='\t', index=False)
678+
622679
def export_quant_matrix(self, data: pd.DataFrame) -> pd.DataFrame:
623680
"""
624681
Export quantification matrix at specified level with optional normalization.

pyprophet/io/export/osw.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,9 @@ def _read_sqlite(self, con):
119119
"""Main entry point for reading SQLite data, delegates to specific methods."""
120120
cfg = self.config
121121

122+
if self.config.export_format == "library":
123+
raise NotImplementedError("Library export from sqlite OSW files is not supported")
124+
122125
if self._is_unscored_file(con):
123126
logger.info("Reading unscored data from Parquet file.")
124127
return self._read_unscored_data(con)

pyprophet/io/export/parquet.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ def read(self) -> pd.DataFrame:
3636
try:
3737
self._init_duckdb_views(con)
3838

39+
if self.config.export_format == "library":
40+
raise NotImplementedError("Library export from non-split .parquet files is not supported")
41+
3942
if self._is_unscored_file():
4043
logger.info("Reading unscored data from Parquet file.")
4144
return self._read_unscored_data(con)

pyprophet/io/export/split_parquet.py

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,18 @@ def read(self) -> pd.DataFrame:
6868
try:
6969
self._init_duckdb_views(con)
7070

71+
if self.config.export_format == "library":
72+
if self._is_unscored_file():
73+
descr= "Files must be scored for library generation."
74+
logger.exception(descr)
75+
raise ValueError(descr)
76+
if not self._has_peptide_protein_global_scores():
77+
descr= "Files must have peptide and protein level global scores for library generation."
78+
logger.exception(descr)
79+
raise ValueError(descr)
80+
logger.info("Reading standard OpenSWATH data for library from split Parquet files.")
81+
return self._read_library_data(con)
82+
7183
if self._is_unscored_file():
7284
logger.info("Reading unscored data from split Parquet files.")
7385
return self._read_unscored_data(con)
@@ -82,9 +94,17 @@ def read(self) -> pd.DataFrame:
8294
logger.info("Reading standard OpenSWATH data from split Parquet files.")
8395
data = self._read_standard_data(con)
8496

85-
return self._augment_data(data, con)
97+
return self._augment_data(data, con)
8698
finally:
8799
con.close()
100+
101+
def _has_peptide_protein_global_scores(self) -> bool:
102+
"""
103+
Check if files contain peptide and protein global scores
104+
"""
105+
has_peptide = any(col.startswith("SCORE_PEPTIDE_GLOBAL") for col in self._columns)
106+
has_protein = any(col.startswith("SCORE_PROTEIN_GLOBAL") for col in self._columns)
107+
return has_peptide and has_protein
88108

89109
def _is_unscored_file(self) -> bool:
90110
"""
@@ -257,6 +277,66 @@ def _read_augmented_data(self, con) -> pd.DataFrame:
257277

258278
return pd.merge(data, ipf_data, on="id", how="left")
259279

280+
def _read_library_data(self, con) -> pd.DataFrame:
281+
"""
282+
Read data specifically for precursors for library generation. This does not include all output in standard output
283+
"""
284+
if self.config.rt_calibration:
285+
rt_col = "p.EXP_RT"
286+
else:
287+
rt_col = "p.PRECURSOR_LIBRARY_RT"
288+
289+
if self.config.im_calibration:
290+
im_col = "p.EXP_IM"
291+
else:
292+
im_col = "p.PRECURSOR_LIBRARY_DRIFT_TIME"
293+
294+
if self.config.intensity_calibration:
295+
intensity_col = 't.FEATURE_TRANSITION_AREA_INTENSITY'
296+
else:
297+
intensity_col = 't.TRANSITION_LIBRARY_INTENSITY'
298+
299+
if self.config.keep_decoys:
300+
decoy_query = ""
301+
else:
302+
decoy_query ="p.PRECURSOR_DECOY is false and t.TRANSITION_DECOY is false and"
303+
304+
query = f"""
305+
SELECT
306+
{rt_col} as NormalizedRetentionTime,
307+
{im_col} as PrecursorIonMobility,
308+
{intensity_col} as LibraryIntensity,
309+
p.SCORE_MS2_Q_VALUE as Q_Value,
310+
p.UNMODIFIED_SEQUENCE AS PeptideSequence,
311+
p.MODIFIED_SEQUENCE AS ModifiedPeptideSequence,
312+
p.PRECURSOR_CHARGE AS PrecursorCharge,
313+
p.FEATURE_MS2_AREA_INTENSITY AS Intensity,
314+
p.RUN_ID AS RunId,
315+
(p.MODIFIED_SEQUENCE || '_' || CAST(p.PRECURSOR_CHARGE AS VARCHAR)) AS Precursor,
316+
p.PRECURSOR_MZ AS PrecursorMz,
317+
STRING_AGG(p.PROTEIN_ACCESSION, ';') AS ProteinName,
318+
t.ANNOTATION as Annotation,
319+
t.PRODUCT_MZ as ProductMz,
320+
t.TRANSITION_CHARGE as FragmentCharge,
321+
t.TRANSITION_TYPE as FragmentType,
322+
t.TRANSITION_ORDINAL as FragmentSeriesNumber,
323+
t.TRANSITION_ID as TransitionId,
324+
p.PRECURSOR_DECOY as Decoy
325+
FROM precursors p
326+
INNER JOIN transition t ON p.FEATURE_ID = t.FEATURE_ID
327+
WHERE {decoy_query}
328+
p.SCORE_MS2_Q_VALUE < {self.config.max_rs_peakgroup_qvalue} and
329+
p.SCORE_PROTEIN_GLOBAL_Q_VALUE < {self.config.max_global_protein_qvalue} and
330+
p.SCORE_PEPTIDE_GLOBAL_Q_VALUE < {self.config.max_global_peptide_qvalue} and
331+
p.SCORE_MS2_PEAK_GROUP_RANK = 1
332+
333+
GROUP BY {rt_col}, {im_col}, {intensity_col}, p.SCORE_MS2_Q_VALUE,
334+
p.UNMODIFIED_SEQUENCE, p.MODIFIED_SEQUENCE, p.PRECURSOR_CHARGE,
335+
p.PRECURSOR_MZ, p.FEATURE_ID, t.ANNOTATION, t.PRODUCT_MZ,
336+
t.TRANSITION_CHARGE, t.TRANSITION_TYPE, t.TRANSITION_ORDINAL, t.TRANSITION_ID, p.PRECURSOR_DECOY, p.RUN_ID, p.FEATURE_MS2_AREA_INTENSITY
337+
"""
338+
return con.execute(query).fetchdf()
339+
260340
def _read_standard_data(self, con) -> pd.DataFrame:
261341
"""
262342
Read standard OpenSWATH data without IPF from split files.

tests/_regtest_outputs/test_pyprophet_export.test_osw_analysis_libExport[osw-False-RT].out

Whitespace-only changes.

tests/_regtest_outputs/test_pyprophet_export.test_osw_analysis_libExport[osw-False-iRT].out

Whitespace-only changes.

tests/_regtest_outputs/test_pyprophet_export.test_osw_analysis_libExport[osw-True-RT].out

Whitespace-only changes.

tests/_regtest_outputs/test_pyprophet_export.test_osw_analysis_libExport[osw-True-iRT].out

Whitespace-only changes.

0 commit comments

Comments
 (0)