Skip to content

Commit 9a71619

Browse files
wsnobleclaude
andcommitted
Phase 2: streaming writer and chunked text parser
writers/txt.py: - Stream each confidence-estimate DataFrame directly to CSV instead of materialising the full concatenated result first. Uses mode='a' + header=False for all but the first chunk, eliminating the peak-memory spike from pd.concat() before the write. parsers/txt.py: - Add chunk_size parameter to read_txt(). When set, returns a generator that yields one pandas DataFrame per chunk_size rows, with the target column already converted to bool. The non-chunked path is unchanged. Chunked mode is intended as the ingestion layer for the Phase 3 DuckDB backend. utils.py: - Add chunk_size parameter to parse_psms_txt(). When set, returns a pandas TextFileReader iterator instead of a full DataFrame, allowing callers (e.g. Tide/Comet/MSGF+ parsers) to read large files row-batch by row-batch. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 07540af commit 9a71619

3 files changed

Lines changed: 85 additions & 18 deletions

File tree

crema/parsers/txt.py

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ def read_txt(
2020
sep="\t",
2121
pairing_file_name=None,
2222
copy_data=False,
23+
chunk_size=None,
2324
):
2425
"""Read peptide-spectrum matches (PSMs) from delimited text files.
2526
@@ -57,12 +58,23 @@ def read_txt(
5758
is safer because it prevents accidental modification of the underlying
5859
data. This argument only has an effect when `pin_files` is a
5960
:py:class:`pandas.DataFrame`
61+
chunk_size : int or None, optional
62+
When set, the parser operates in streaming mode: instead of loading all
63+
files into memory at once, it returns a generator that yields one
64+
:py:class:`pandas.DataFrame` per chunk of ``chunk_size`` rows. This is
65+
useful for feeding data into a DuckDB relation or a Parquet writer
66+
without ever holding the full dataset in RAM. When ``None`` (default),
67+
the existing behaviour is preserved and a
68+
:py:class:`~crema.dataset.PsmDataset` is returned.
6069
6170
Returns
6271
-------
63-
PsmDataset
64-
A :py:class:`~crema.dataset.PsmDataset` object containing the parsed
65-
PSMs.
72+
PsmDataset or generator of pandas.DataFrame
73+
When ``chunk_size`` is ``None``, a
74+
:py:class:`~crema.dataset.PsmDataset` object containing the parsed
75+
PSMs. When ``chunk_size`` is set, a generator of
76+
:py:class:`pandas.DataFrame` chunks (each with the target column
77+
already converted to bool).
6678
"""
6779
# Store column names in a list to be used by read_csv function
6880
fields = [target_column, peptide_column, protein_column]
@@ -72,6 +84,12 @@ def read_txt(
7284
score_columns = utils.listify(score_columns)
7385
fields += spectrum_columns + score_columns
7486

87+
# Streaming mode: yield chunks without constructing a PsmDataset
88+
if chunk_size is not None and not isinstance(txt_files, pd.DataFrame):
89+
return _read_txt_chunked(
90+
utils.listify(txt_files), sep, fields, target_column, chunk_size
91+
)
92+
7593
# Parse the data
7694
if isinstance(txt_files, pd.DataFrame):
7795
data = txt_files.copy(deep=copy_data).loc[:, fields]
@@ -100,6 +118,38 @@ def read_txt(
100118
return psms
101119

102120

121+
def _read_txt_chunked(txt_files, sep, cols, target_column, chunk_size):
122+
"""Yield DataFrames of ``chunk_size`` rows from one or more text files.
123+
124+
Parameters
125+
----------
126+
txt_files : list of str
127+
The files to read.
128+
sep : str
129+
The delimiter.
130+
cols : list of str
131+
The columns to retain.
132+
target_column : str
133+
The column containing target/decoy labels (converted to bool per chunk).
134+
chunk_size : int
135+
Number of rows per chunk.
136+
137+
Yields
138+
------
139+
pandas.DataFrame
140+
A chunk with the target column already converted to bool.
141+
"""
142+
for txt_file in txt_files:
143+
LOGGER.info(
144+
"Reading PSMs from %s in chunks of %d...", txt_file, chunk_size
145+
)
146+
for chunk in pd.read_csv(
147+
txt_file, sep=sep, usecols=cols, chunksize=chunk_size
148+
):
149+
chunk[target_column] = _convert_target_col(chunk[target_column])
150+
yield chunk
151+
152+
103153
def _parse_psms(txt_file, sep, cols):
104154
"""Parse a single delimited txt file.
105155

crema/utils.py

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ def create_pairing_from_file(pairing_file_name):
6565
return dict(zip(pairing_file[target_field], pairing_file[decoy_field]))
6666

6767

68-
def parse_psms_txt(txt_file, cols, skip_line):
68+
def parse_psms_txt(txt_file, cols, skip_line, chunk_size=None):
6969
"""Parse a single tab-delimited file
7070
7171
Parameters
@@ -76,11 +76,17 @@ def parse_psms_txt(txt_file, cols, skip_line):
7676
The columns to parse.
7777
skip_line : bool
7878
If true, skip reading the first line.
79+
chunk_size : int or None, optional
80+
When set, return a :py:class:`pandas.io.parsers.TextFileReader`
81+
iterator that yields ``chunk_size``-row DataFrames instead of loading
82+
the whole file. When ``None`` (default), the full DataFrame is
83+
returned.
7984
8085
Returns
8186
-------
82-
pandas.DataFrame
83-
A :py:class:`pandas.DataFrame` containing the parsed PSMs
87+
pandas.DataFrame or pandas.io.parsers.TextFileReader
88+
A :py:class:`pandas.DataFrame` containing the parsed PSMs, or an
89+
iterator of chunks when ``chunk_size`` is set.
8490
"""
8591
LOGGER.info("Reading PSMs from %s...", txt_file)
8692

@@ -102,15 +108,20 @@ def parse_psms_txt(txt_file, cols, skip_line):
102108
header = fh.readline().rstrip("\n").split("\t")
103109
explicit_cols = [c for c in header if c in cols]
104110
except UnicodeDecodeError:
111+
if chunk_size is not None:
112+
fallback_kwargs["chunksize"] = chunk_size
105113
return pd.read_csv(txt_file, **fallback_kwargs)
106114

115+
read_kwargs = dict(
116+
sep="\t",
117+
skiprows=int(skip_line),
118+
usecols=explicit_cols,
119+
)
120+
if chunk_size is not None:
121+
read_kwargs["chunksize"] = chunk_size
122+
return pd.read_csv(txt_file, **read_kwargs)
123+
107124
try:
108-
return pd.read_csv(
109-
txt_file,
110-
sep="\t",
111-
skiprows=int(skip_line),
112-
usecols=explicit_cols,
113-
engine="pyarrow",
114-
)
125+
return pd.read_csv(txt_file, engine="pyarrow", **read_kwargs)
115126
except (ImportError, ValueError):
116127
return pd.read_csv(txt_file, **fallback_kwargs)

crema/writers/txt.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@
33
from pathlib import Path
44
from collections import defaultdict
55

6-
import pandas as pd
7-
86

97
def to_txt(
108
conf, output_dir=None, file_root=None, sep="\t", decoys=False, precision=6
@@ -64,9 +62,17 @@ def to_txt(
6462
out_files = []
6563
for level, qval_list in results.items():
6664
out_file = str(file_base) + f".{level}.txt"
67-
pd.concat(qval_list).to_csv(
68-
out_file, sep=sep, index=False, float_format=f"%.{precision}f"
69-
)
65+
first = True
66+
for df in qval_list:
67+
df.to_csv(
68+
out_file,
69+
sep=sep,
70+
index=False,
71+
float_format=f"%.{precision}f",
72+
header=first,
73+
mode="w" if first else "a",
74+
)
75+
first = False
7076
out_files.append(out_file)
7177

7278
return out_files

0 commit comments

Comments
 (0)