Skip to content

Commit 4344e2b

Browse files
authored
Merge pull request #213 from singjc/split/osw-export-merge
feat: optimize OSW export and merge workflows
2 parents fcf2e7e + 0ff6b3c commit 4344e2b

11 files changed

Lines changed: 869 additions & 487 deletions

.dockerignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ dist/
1111
tests/
1212
notebooks/
1313
data/
14+
!tools/osw_to_parquet_rust/data/
15+
!tools/osw_to_parquet_rust/data/unimod.xml
1416
examples/
1517
.tmp/
1618
.DS_Store
1719
sandbox/
1820
.pytest_cache/
19-
.ruff_cache/
21+
.ruff_cache/

pyprophet/_config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -752,6 +752,7 @@ class ExportIOConfig(BaseIOConfig):
752752
split_transition_data: bool = True
753753
split_runs: bool = False
754754
include_transition_data: bool = True # Whether to include transition data in parquet export
755+
exclude_feature_var: bool = False # Whether to exclude FEATURE_MS1/MS2 variance (VAR_*) columns
755756

756757
# SqMass: Export to parquet
757758
pqp_file: Optional[str] = None # Path to PQP file for precursor/transition mapping

pyprophet/cli/export.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -617,6 +617,13 @@ def export_library(
617617
show_default=True,
618618
help="Include transition data in the exported parquet file(s). When disabled, only precursor-level data is exported.",
619619
)
620+
@click.option(
621+
"--exclude_feature_var/--no-exclude_feature_var",
622+
"exclude_feature_var",
623+
default=False,
624+
show_default=True,
625+
help="Exclude feature variance columns (VAR_*) from FEATURE_MS1 and FEATURE_MS2 tables. Significantly speeds up export and reduces file size.",
626+
)
620627
@measure_memory_usage_and_time
621628
def export_parquet(
622629
infile,
@@ -630,6 +637,7 @@ def export_parquet(
630637
compression,
631638
compression_level,
632639
include_transition_data,
640+
exclude_feature_var,
633641
):
634642
"""
635643
Export OSW or sqMass to parquet format
@@ -666,6 +674,7 @@ def export_parquet(
666674
compression_method=compression,
667675
compression_level=compression_level,
668676
include_transition_data=include_transition_data,
677+
exclude_feature_var=exclude_feature_var,
669678
)
670679

671680
writer = WriterDispatcher.get_writer(config)

pyprophet/cli/merge.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,13 @@ def merge():
5959
is_flag=True,
6060
help="Merge OSW output files that have already been scored.",
6161
)
62+
@click.option(
63+
"--fresh",
64+
is_flag=True,
65+
help="Start from scratch, ignoring any existing merged.osw file. If enabled, removes existing output file before starting.",
66+
)
6267
@measure_memory_usage_and_time
63-
def merge_osw(infiles, outfile, same_run, templatefile, merged_post_scored_runs):
68+
def merge_osw(infiles, outfile, same_run, templatefile, merged_post_scored_runs, fresh):
6469
"""
6570
Merge multiple OSW files and (for large experiments, it is recommended to subsample first).
6671
"""
@@ -70,7 +75,7 @@ def merge_osw(infiles, outfile, same_run, templatefile, merged_post_scored_runs)
7075
"At least one PyProphet input file needs to be provided."
7176
)
7277

73-
_merge_osw(infiles, outfile, templatefile, same_run, merged_post_scored_runs)
78+
_merge_osw(infiles, outfile, templatefile, same_run, merged_post_scored_runs, fresh)
7479

7580

7681
@click.command(name="parquet", cls=AdvancedHelpCommand)

pyprophet/io/export/osw.py

Lines changed: 169 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,10 @@ def _read_unscored_data(self, con):
264264

265265
def _build_score_sql(self, con):
266266
"""Build SQL fragment for score columns in unscored files."""
267+
# Skip if exclude_feature_var is enabled
268+
if self.config.exclude_feature_var:
269+
return ""
270+
267271
score_sql = ""
268272
if check_sqlite_table(con, "FEATURE_MS1"):
269273
score_sql = write_scores_sql_command(
@@ -1516,7 +1520,23 @@ def _write_parquet(self) -> None:
15161520
def _convert_to_split_parquet(self) -> None:
15171521
"""Convert OSW to split parquet format"""
15181522
conn = duckdb.connect(":memory:")
1519-
load_sqlite_scanner(conn)
1523+
1524+
try:
1525+
load_sqlite_scanner(conn)
1526+
except Exception as scanner_error:
1527+
# If sqlite_scanner fails to load (e.g., in containers without internet),
1528+
# provide helpful guidance but continue with fallback
1529+
if "Failed to download extension" in str(scanner_error) or "Connection timed out" in str(scanner_error):
1530+
click.echo(
1531+
"Warning: sqlite_scanner extension could not be loaded (likely in container without internet access).\n"
1532+
"To fix: Set DUCKDB_EXTENSION_DIRECTORY environment variable to a directory with pre-downloaded extensions.\n"
1533+
"Or pre-download extensions on your host with: "
1534+
"python3 -c 'import duckdb; duckdb.connect(\":memory:\").execute(\"LOAD sqlite_scanner\")'\n"
1535+
"Continuing with alternative method...",
1536+
err=True
1537+
)
1538+
else:
1539+
raise
15201540

15211541
try:
15221542
# Prepare column information
@@ -1533,7 +1553,23 @@ def _convert_to_split_parquet(self) -> None:
15331553
def _convert_to_single_parquet(self) -> None:
15341554
"""Convert OSW to single parquet file"""
15351555
conn = duckdb.connect(":memory:")
1536-
load_sqlite_scanner(conn)
1556+
1557+
try:
1558+
load_sqlite_scanner(conn)
1559+
except Exception as scanner_error:
1560+
# If sqlite_scanner fails to load (e.g., in containers without internet),
1561+
# provide helpful guidance but continue with fallback
1562+
if "Failed to download extension" in str(scanner_error) or "Connection timed out" in str(scanner_error):
1563+
click.echo(
1564+
"Warning: sqlite_scanner extension could not be loaded (likely in container without internet access).\n"
1565+
"To fix: Set DUCKDB_EXTENSION_DIRECTORY environment variable to a directory with pre-downloaded extensions.\n"
1566+
"Or pre-download extensions on your host with: "
1567+
"python3 -c 'import duckdb; duckdb.connect(\":memory:\").execute(\"LOAD sqlite_scanner\")'\n"
1568+
"Continuing with alternative method...",
1569+
err=True
1570+
)
1571+
else:
1572+
raise
15371573

15381574
try:
15391575
# Prepare column information
@@ -1614,9 +1650,45 @@ def _prepare_column_info(self, conn) -> dict:
16141650
column_info["score_peptide_contexts"] = self._check_contexts(
16151651
sql_conn, "SCORE_PEPTIDE"
16161652
)
1653+
1654+
# Create necessary indices to speed up joins
1655+
logger.info("Creating indices for faster export")
1656+
self._create_export_indices(sql_conn)
16171657

16181658
return column_info
16191659

1660+
def _create_export_indices(self, sql_conn: sqlite3.Connection) -> None:
1661+
"""Create indices to optimize join performance during export"""
1662+
indices_to_create = [
1663+
("PRECURSOR_PEPTIDE_MAPPING", "PRECURSOR_ID", "idx_ppm_precursor_id"),
1664+
("PRECURSOR_PEPTIDE_MAPPING", "PEPTIDE_ID", "idx_ppm_peptide_id"),
1665+
("PEPTIDE_PROTEIN_MAPPING", "PEPTIDE_ID", "idx_pprotm_peptide_id"),
1666+
("PEPTIDE_PROTEIN_MAPPING", "PROTEIN_ID", "idx_pprotm_protein_id"),
1667+
("PEPTIDE_GENE_MAPPING", "PEPTIDE_ID", "idx_pgm_peptide_id"),
1668+
("PEPTIDE_GENE_MAPPING", "GENE_ID", "idx_pgm_gene_id"),
1669+
("FEATURE", "PRECURSOR_ID", "idx_feat_precursor_id"),
1670+
("FEATURE", "RUN_ID", "idx_feat_run_id"),
1671+
("FEATURE_MS1", "FEATURE_ID", "idx_feat_ms1_feature_id"),
1672+
("FEATURE_MS2", "FEATURE_ID", "idx_feat_ms2_feature_id"),
1673+
("FEATURE_TRANSITION", "FEATURE_ID", "idx_feat_trans_feature_id"),
1674+
("FEATURE_TRANSITION", "TRANSITION_ID", "idx_feat_trans_trans_id"),
1675+
("TRANSITION_PRECURSOR_MAPPING", "TRANSITION_ID", "idx_tpm_transition_id"),
1676+
("TRANSITION_PRECURSOR_MAPPING", "PRECURSOR_ID", "idx_tpm_precursor_id"),
1677+
("TRANSITION_PEPTIDE_MAPPING", "TRANSITION_ID", "idx_tpeptm_transition_id"),
1678+
("TRANSITION_PEPTIDE_MAPPING", "PEPTIDE_ID", "idx_tpeptm_peptide_id"),
1679+
]
1680+
1681+
for table, column, index_name in indices_to_create:
1682+
try:
1683+
sql_conn.execute(
1684+
f"CREATE INDEX IF NOT EXISTS {index_name} ON {table}({column})"
1685+
)
1686+
except sqlite3.OperationalError as e:
1687+
logger.debug(f"Could not create index {index_name}: {e}")
1688+
1689+
sql_conn.commit()
1690+
logger.debug("Indices created for export optimization")
1691+
16201692
def _export_split_by_run(self, conn, column_info: dict) -> None:
16211693
"""Export data split by run into separate directories"""
16221694
os.makedirs(self.config.outfile, exist_ok=True)
@@ -1707,30 +1779,24 @@ def _export_combined(self, conn, column_info: dict) -> None:
17071779
self._export_alignment_data(conn)
17081780

17091781
def _export_single_file(self, conn, column_info: dict) -> None:
1710-
"""Export all data to a single parquet file"""
1711-
# Create temp table with combined schema
1712-
logger.debug("Creating temporary table for combined export")
1713-
self._create_temp_table(conn, column_info)
1782+
"""Export all data to a single parquet file using streaming (UNION ALL)"""
1783+
logger.info(f"Exporting combined data to {self.config.outfile}")
17141784

1715-
# Insert precursor data
1716-
logger.debug("Inserting precursor data into temp table")
1785+
# Build precursor query
17171786
precursor_query = self._build_combined_precursor_query(conn, column_info)
1718-
# print(precursor_query)
1719-
conn.execute(f"INSERT INTO temp_table {precursor_query}")
17201787

1721-
# Insert transition data if requested
1788+
# Build combined query
17221789
if self.config.include_transition_data:
1723-
logger.debug("Inserting transition data into temp table")
1790+
logger.debug("Including transition data in export")
17241791
transition_query = self._build_combined_transition_query(column_info)
1725-
conn.execute(f"INSERT INTO temp_table {transition_query}")
1792+
# Combine queries with UNION ALL - this streams directly to parquet
1793+
combined_query = f"{precursor_query}\nUNION ALL\n{transition_query}"
17261794
else:
1727-
logger.info(
1728-
"Skipping transition data export (include_transition_data=False)"
1729-
)
1795+
logger.info("Skipping transition data export (include_transition_data=False)")
1796+
combined_query = precursor_query
17301797

1731-
# Export to parquet
1732-
logger.info(f"Exporting combined data to {self.config.outfile}")
1733-
self._execute_copy_query(conn, "SELECT * FROM temp_table", self.config.outfile)
1798+
# Stream directly to parquet file without intermediate temp table
1799+
self._execute_copy_query(conn, combined_query, self.config.outfile)
17341800

17351801
# Export alignment data if exists
17361802
if column_info["feature_ms2_alignment_exists"]:
@@ -1775,44 +1841,16 @@ def _register_peptide_ipf_map(self, conn: duckdb.DuckDBPyConnection) -> None:
17751841
)
17761842

17771843
def _create_unimod_to_codename_peptide_id_mapping_table(self) -> None:
1778-
"""Create peptide unimod to codename mapping table in SQLite database."""
1844+
"""Create peptide unimod to codename mapping table in SQLite database.
1845+
1846+
Processes peptides in chunks to reduce memory footprint for large datasets.
1847+
"""
17791848
logger.info(
17801849
"Generating peptide unimod to codename mapping and storing in SQLite"
17811850
)
17821851

17831852
with sqlite3.connect(self.config.infile) as sql_conn:
1784-
# First get the peptide table and process it with pyopenms
1785-
peptide_df = pd.read_sql_query(
1786-
"SELECT ID, MODIFIED_SEQUENCE FROM PEPTIDE", sql_conn
1787-
)
1788-
1789-
peptide_df["codename"] = peptide_df["MODIFIED_SEQUENCE"].apply(
1790-
unimod_to_codename
1791-
)
1792-
1793-
# Create the merged mapping
1794-
unimod_mask = peptide_df["MODIFIED_SEQUENCE"].str.contains("UniMod")
1795-
merged_df = pd.merge(
1796-
peptide_df[unimod_mask][["codename", "ID"]],
1797-
peptide_df[~unimod_mask][["codename", "ID"]],
1798-
on="codename",
1799-
suffixes=("_unimod", "_codename"),
1800-
how="outer",
1801-
)
1802-
1803-
# Fill NaN values in the 'ID_codename' column with the 'ID_unimod' values
1804-
merged_df["ID_codename"] = merged_df["ID_codename"].fillna(
1805-
merged_df["ID_unimod"]
1806-
)
1807-
# Fill NaN values in the 'ID_unimod' column with the 'ID_codename' values
1808-
merged_df["ID_unimod"] = merged_df["ID_unimod"].fillna(
1809-
merged_df["ID_codename"]
1810-
)
1811-
1812-
merged_df["ID_unimod"] = merged_df["ID_unimod"].astype(int)
1813-
merged_df["ID_codename"] = merged_df["ID_codename"].astype(int)
1814-
1815-
# Create the UNIMOD_TO_CODENAME_PEPTIDE_ID_MAPPING table in SQLite
1853+
# Create the mapping table first
18161854
sql_conn.execute(
18171855
"""
18181856
CREATE TABLE IF NOT EXISTS UNIMOD_TO_CODENAME_PEPTIDE_ID_MAPPING (
@@ -1824,14 +1862,73 @@ def _create_unimod_to_codename_peptide_id_mapping_table(self) -> None:
18241862
"""
18251863
)
18261864
sql_conn.execute("DELETE FROM UNIMOD_TO_CODENAME_PEPTIDE_ID_MAPPING")
1865+
sql_conn.commit()
18271866

1828-
# Insert the data into SQLite table
1829-
merged_df[["ID_unimod", "ID_codename", "codename"]].to_sql(
1830-
"UNIMOD_TO_CODENAME_PEPTIDE_ID_MAPPING",
1831-
sql_conn,
1832-
if_exists="append",
1833-
index=False,
1834-
)
1867+
# Get total count for progress tracking
1868+
total_count = sql_conn.execute(
1869+
"SELECT COUNT(*) FROM PEPTIDE"
1870+
).fetchone()[0]
1871+
logger.info(f"Processing {total_count} peptides in chunks")
1872+
1873+
# Process peptides in chunks to reduce memory footprint
1874+
chunk_size = 50000 # Process 50k peptides at a time
1875+
processed = 0
1876+
1877+
while processed < total_count:
1878+
# Fetch chunk of peptides
1879+
peptide_chunk = pd.read_sql_query(
1880+
f"""SELECT ID, MODIFIED_SEQUENCE FROM PEPTIDE
1881+
LIMIT {chunk_size} OFFSET {processed}""",
1882+
sql_conn,
1883+
)
1884+
1885+
if peptide_chunk.empty:
1886+
break
1887+
1888+
# Process chunk
1889+
peptide_chunk["codename"] = peptide_chunk["MODIFIED_SEQUENCE"].apply(
1890+
unimod_to_codename
1891+
)
1892+
1893+
# Create mapping for this chunk
1894+
unimod_mask = peptide_chunk["MODIFIED_SEQUENCE"].str.contains("UniMod", na=False)
1895+
unimod_chunk = peptide_chunk[unimod_mask][["codename", "ID"]].copy()
1896+
unimod_chunk.columns = ["codename", "ID_unimod"]
1897+
1898+
codename_chunk = peptide_chunk[~unimod_mask][["codename", "ID"]].copy()
1899+
codename_chunk.columns = ["codename", "ID_codename"]
1900+
1901+
# Merge on codename
1902+
merged_chunk = pd.merge(
1903+
unimod_chunk,
1904+
codename_chunk,
1905+
on="codename",
1906+
how="outer",
1907+
)
1908+
1909+
# Fill NaN values
1910+
merged_chunk["ID_codename"] = merged_chunk["ID_codename"].fillna(
1911+
merged_chunk["ID_unimod"]
1912+
)
1913+
merged_chunk["ID_unimod"] = merged_chunk["ID_unimod"].fillna(
1914+
merged_chunk["ID_codename"]
1915+
)
1916+
1917+
merged_chunk["ID_unimod"] = merged_chunk["ID_unimod"].astype(int)
1918+
merged_chunk["ID_codename"] = merged_chunk["ID_codename"].astype(int)
1919+
1920+
# Insert chunk into SQLite
1921+
merged_chunk[["ID_unimod", "ID_codename", "codename"]].to_sql(
1922+
"UNIMOD_TO_CODENAME_PEPTIDE_ID_MAPPING",
1923+
sql_conn,
1924+
if_exists="append",
1925+
index=False,
1926+
)
1927+
1928+
processed += len(peptide_chunk)
1929+
logger.debug(f"Processed {processed}/{total_count} peptides")
1930+
1931+
sql_conn.commit()
18351932

18361933
# Create indices for better performance
18371934
sql_conn.execute(
@@ -1845,8 +1942,12 @@ def _create_unimod_to_codename_peptide_id_mapping_table(self) -> None:
18451942
)
18461943

18471944
sql_conn.commit()
1945+
1946+
final_count = sql_conn.execute(
1947+
"SELECT COUNT(*) FROM UNIMOD_TO_CODENAME_PEPTIDE_ID_MAPPING"
1948+
).fetchone()[0]
18481949
logger.info(
1849-
f"Successfully created UNIMOD_TO_CODENAME_PEPTIDE_ID_MAPPING table with {len(merged_df)} mappings"
1950+
f"Successfully created UNIMOD_TO_CODENAME_PEPTIDE_ID_MAPPING table with {final_count} mappings"
18501951
)
18511952

18521953
def _insert_precursor_peptide_ipf_map(self) -> None:
@@ -2472,7 +2573,7 @@ def _export_alignment_data(self, conn, path: str = None) -> None:
24722573
has_score_alignment = check_sqlite_table(sql_conn, "SCORE_ALIGNMENT")
24732574

24742575
if has_score_alignment:
2475-
# Export with alignment scores
2576+
# Export with alignment scores - use ROW_NUMBER to get best score per feature
24762577
query = f"""
24772578
SELECT
24782579
FEATURE_MS2_ALIGNMENT.ALIGNMENT_ID,
@@ -2496,9 +2597,13 @@ def _export_alignment_data(self, conn, path: str = None) -> None:
24962597
SCORE_ALIGNMENT.QVALUE AS QVALUE
24972598
FROM sqlite_scan('{self.config.infile}', 'FEATURE_MS2_ALIGNMENT') AS FEATURE_MS2_ALIGNMENT
24982599
LEFT JOIN (
2499-
SELECT FEATURE_ID, SCORE, PEP, QVALUE, MIN(QVALUE) as MIN_QVALUE
2500-
FROM sqlite_scan('{self.config.infile}', 'SCORE_ALIGNMENT')
2501-
GROUP BY FEATURE_ID
2600+
SELECT FEATURE_ID, SCORE, PEP, QVALUE
2601+
FROM (
2602+
SELECT FEATURE_ID, SCORE, PEP, QVALUE,
2603+
ROW_NUMBER() OVER (PARTITION BY FEATURE_ID ORDER BY QVALUE ASC) as rn
2604+
FROM sqlite_scan('{self.config.infile}', 'SCORE_ALIGNMENT')
2605+
) t
2606+
WHERE rn = 1
25022607
) AS SCORE_ALIGNMENT
25032608
ON FEATURE_MS2_ALIGNMENT.ALIGNED_FEATURE_ID = SCORE_ALIGNMENT.FEATURE_ID
25042609
"""

0 commit comments

Comments
 (0)