Skip to content

Commit 8ef2f98

Browse files
authored
Merge pull request #26 from IEDB/counts-only
Add counts_only aggregate mode
2 parents cc706ba + 4152c01 commit 8ef2f98

4 files changed

Lines changed: 214 additions & 1 deletion

File tree

pepmatch/matcher.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import polars as pl
33
from pathlib import Path
44
from Bio import SeqIO
5-
from ._rs import rs_preprocess, rs_match, rs_discontinuous, rs_metadata
5+
from ._rs import rs_preprocess, rs_match, rs_discontinuous, rs_metadata, rs_match_counts
66

77
VALID_OUTPUT_FORMATS = ['dataframe', 'csv', 'tsv', 'xlsx', 'json']
88
FASTA_EXTENSIONS = {
@@ -37,6 +37,7 @@ def __init__(
3737
output_format='dataframe',
3838
output_name='',
3939
sequence_version=True,
40+
counts_only=False,
4041
):
4142

4243
if best_match and k == 0:
@@ -57,13 +58,17 @@ def __init__(
5758
f'Invalid output format. Choose from: {VALID_OUTPUT_FORMATS}'
5859
)
5960

61+
if counts_only and best_match:
62+
raise ValueError('counts_only is not supported with best_match.')
63+
6064
self.proteome_file = str(proteome_file)
6165
self.proteome_name = str(proteome_file).split('/')[-1].split('.')[0]
6266
self.max_mismatches = max_mismatches
6367
self.preprocessed_files_path = preprocessed_files_path
6468
self.best_match = best_match
6569
self.output_format = output_format
6670
self.sequence_version = sequence_version
71+
self.counts_only = counts_only
6772
self.output_name = output_name or 'PEPMatch_results'
6873

6974
self.query = self._parse_query(query)
@@ -116,6 +121,12 @@ def match(self):
116121
linear_df = pl.DataFrame()
117122
discontinuous_df = pl.DataFrame()
118123

124+
if self.counts_only:
125+
df = self._counts_to_dataframe(self._search_counts(self.k, self.max_mismatches))
126+
if self.output_format == 'dataframe':
127+
return df
128+
return output_matches(df, self.output_format, self.output_name)
129+
119130
if self.query:
120131
if self.best_match and self.k_specified:
121132
results = self._search(self.k, self.max_mismatches)
@@ -158,6 +169,30 @@ def _search(self, k, max_mismatches, peptides=None):
158169
print(f"Searching {len(query)} peptides against {self.proteome_name} (k={k}, max_mismatches={max_mismatches})...")
159170
return rs_match(pepidx_path, query, k, max_mismatches)
160171

172+
def _search_counts(self, k, max_mismatches):
173+
pepidx_path = self._pepidx_path(k)
174+
if not os.path.isfile(pepidx_path):
175+
print(f"Preprocessing {self.proteome_name} with k={k}...")
176+
rs_preprocess(self.proteome_file, k, pepidx_path)
177+
print(f"Counting {len(self.query)} peptides against {self.proteome_name} (k={k}, max_mismatches={max_mismatches})...")
178+
return rs_match_counts(pepidx_path, self.query, k, max_mismatches)
179+
180+
def _counts_to_dataframe(self, cols):
181+
"""Aggregate counts: one row per (query peptide, mismatch level) with a hit.
182+
Memory is O(unique queries), independent of total hit count."""
183+
qid, qseq, mm, count = cols
184+
if not qid:
185+
return pl.DataFrame(schema={
186+
'Query ID': pl.Utf8, 'Query Sequence': pl.Utf8,
187+
'Mismatches': pl.Int64, 'Count': pl.UInt64,
188+
})
189+
return pl.DataFrame({
190+
'Query ID': qid,
191+
'Query Sequence': qseq,
192+
'Mismatches': pl.Series(mm, dtype=pl.Int64),
193+
'Count': pl.Series(count, dtype=pl.UInt64),
194+
})
195+
161196
def best_match_search(self):
162197
peptides_remaining = self.query.copy()
163198
acc = tuple([] for _ in range(8)) # 8 columnar accumulators

pepmatch/rs-engine/src/lib.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,18 @@ fn rs_metadata(pepidx_path: &str) -> matching::MetaColumns {
2929
matching::run_metadata(pepidx_path)
3030
}
3131

32+
#[pyfunction]
33+
fn rs_match_counts(pepidx_path: &str, peptides: Vec<(String, String)>, k: usize, max_mismatches: usize) -> matching::CountColumns {
34+
matching::run_counts(pepidx_path, peptides, k, max_mismatches)
35+
}
36+
3237
#[pymodule]
3338
fn _rs(m: &Bound<'_, PyModule>) -> PyResult<()> {
3439
m.add_function(wrap_pyfunction!(rs_version, m)?)?;
3540
m.add_function(wrap_pyfunction!(rs_preprocess, m)?)?;
3641
m.add_function(wrap_pyfunction!(rs_match, m)?)?;
3742
m.add_function(wrap_pyfunction!(rs_discontinuous, m)?)?;
3843
m.add_function(wrap_pyfunction!(rs_metadata, m)?)?;
44+
m.add_function(wrap_pyfunction!(rs_match_counts, m)?)?;
3945
Ok(())
4046
}

pepmatch/rs-engine/src/match.rs

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,3 +541,115 @@ pub(crate) fn run(pepidx_path: &str, peptides: Vec<(String, String)>, k: usize,
541541
.collect();
542542
unzip_records(records)
543543
}
544+
545+
// ── Counts-only path (aggregate; O(unique queries), no per-hit materialization) ──
546+
547+
pub(crate) type CountColumns = (Vec<String>, Vec<String>, Vec<i64>, Vec<u64>);
548+
549+
/// Tally accepted matches per mismatch level for one peptide, mirroring
550+
/// mismatch_match's dedup + validity walk exactly but without building the
551+
/// matched sequence, metadata, or any per-hit row.
552+
fn mismatch_count(peptide: &str, k: usize, max_mismatches: usize, index: &PepIndex, counts: &mut [u64]) {
553+
let pep_bytes = peptide.as_bytes();
554+
if pep_bytes.len() < k { return; }
555+
556+
let num_kmers = pep_bytes.len() - k + 1;
557+
let peptide_len = pep_bytes.len();
558+
let mut seen: HashSet<u64> = HashSet::new();
559+
560+
if peptide_len % k == 0 {
561+
let mut idx = 0;
562+
while idx < num_kmers {
563+
let kmer = &pep_bytes[idx..idx + k];
564+
if let Some(positions) = index.lookup(kmer) {
565+
for kmer_hit in positions {
566+
let start = (kmer_hit as i64 - idx as i64) as u64;
567+
if seen.contains(&start) { continue; }
568+
let mismatches = check_left_neighbors(pep_bytes, idx, kmer_hit, index, k, max_mismatches, 0);
569+
let mismatches = check_right_neighbors(pep_bytes, idx, kmer_hit, index, k, max_mismatches, mismatches);
570+
if mismatches <= max_mismatches {
571+
let mut i = 0;
572+
let mut valid = true;
573+
while i < peptide_len {
574+
if index.resolve(start + i as u64).is_none() { valid = false; break; }
575+
i += k;
576+
}
577+
if valid {
578+
seen.insert(start);
579+
counts[mismatches] += 1;
580+
}
581+
}
582+
}
583+
}
584+
idx += k;
585+
}
586+
} else {
587+
for idx in 0..num_kmers {
588+
let kmer = &pep_bytes[idx..idx + k];
589+
if let Some(positions) = index.lookup(kmer) {
590+
for kmer_hit in positions {
591+
let start = (kmer_hit as i64 - idx as i64) as u64;
592+
if seen.contains(&start) { continue; }
593+
let mismatches = check_left_residues(pep_bytes, idx, kmer_hit, index, max_mismatches, 0);
594+
let mismatches = check_right_residues(pep_bytes, idx, kmer_hit, index, k, max_mismatches, mismatches);
595+
if mismatches <= max_mismatches {
596+
let mut i = 0;
597+
let mut valid = true;
598+
while i < peptide_len {
599+
if i + k > peptide_len {
600+
let remaining = peptide_len - i;
601+
let back = k - remaining;
602+
if index.resolve(start + i as u64 - back as u64).is_none() { valid = false; break; }
603+
} else if index.resolve(start + i as u64).is_none() {
604+
valid = false; break;
605+
}
606+
i += k;
607+
}
608+
if valid {
609+
seen.insert(start);
610+
counts[mismatches] += 1;
611+
}
612+
}
613+
}
614+
}
615+
}
616+
}
617+
}
618+
619+
fn count_peptide(query_id: &str, peptide: &str, k: usize, max_mismatches: usize, index: &PepIndex) -> Vec<(String, String, i64, u64)> {
620+
let mut counts = vec![0u64; max_mismatches + 1];
621+
if max_mismatches == 0 {
622+
counts[0] = exact_match(peptide, k, index).len() as u64;
623+
} else {
624+
mismatch_count(peptide, k, max_mismatches, index, &mut counts);
625+
}
626+
let mut out = Vec::new();
627+
for (mm, &c) in counts.iter().enumerate() {
628+
if c > 0 {
629+
out.push((query_id.to_string(), peptide.to_string(), mm as i64, c));
630+
}
631+
}
632+
out
633+
}
634+
635+
pub(crate) fn run_counts(pepidx_path: &str, peptides: Vec<(String, String)>, k: usize, max_mismatches: usize) -> CountColumns {
636+
let index = PepIndex::open(pepidx_path);
637+
let rows: Vec<(String, String, i64, u64)> = peptides
638+
.par_iter()
639+
.flat_map_iter(|(query_id, peptide)| {
640+
count_peptide(query_id, peptide, k, max_mismatches, &index).into_iter()
641+
})
642+
.collect();
643+
let n = rows.len();
644+
let mut qid = Vec::with_capacity(n);
645+
let mut qseq = Vec::with_capacity(n);
646+
let mut mm = Vec::with_capacity(n);
647+
let mut cnt = Vec::with_capacity(n);
648+
for (a, b, c, d) in rows {
649+
qid.push(a);
650+
qseq.push(b);
651+
mm.push(c);
652+
cnt.push(d);
653+
}
654+
(qid, qseq, mm, cnt)
655+
}

pepmatch/tests/test_counts.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import pytest
2+
import polars as pl
3+
import polars.testing as plt
4+
from pathlib import Path
5+
from pepmatch import Matcher
6+
7+
8+
@pytest.fixture
9+
def proteome_path() -> Path:
10+
return Path(__file__).parent / 'data' / 'proteome.fasta'
11+
12+
@pytest.fixture
13+
def mismatch_query() -> Path:
14+
return Path(__file__).parent / 'data' / 'mismatching_query.fasta'
15+
16+
@pytest.fixture
17+
def exact_query() -> Path:
18+
return Path(__file__).parent / 'data' / 'exact_match_query.fasta'
19+
20+
21+
def _from_full(df: pl.DataFrame) -> pl.DataFrame:
22+
return (
23+
df.filter(pl.col('Matched Sequence').is_not_null())
24+
.group_by(['Query Sequence', 'Mismatches'])
25+
.agg(pl.len().alias('Count'))
26+
.with_columns(pl.col('Count').cast(pl.Int64))
27+
.sort(['Query Sequence', 'Mismatches'])
28+
)
29+
30+
def _from_counts(df: pl.DataFrame) -> pl.DataFrame:
31+
return (
32+
df.group_by(['Query Sequence', 'Mismatches'])
33+
.agg(pl.col('Count').sum().alias('Count'))
34+
.with_columns(pl.col('Count').cast(pl.Int64))
35+
.sort(['Query Sequence', 'Mismatches'])
36+
)
37+
38+
39+
def test_counts_parity_mismatch(proteome_path, mismatch_query):
40+
"""counts_only must equal the full output grouped by (peptide, mismatch level)."""
41+
full = Matcher(query=mismatch_query, proteome_file=proteome_path,
42+
max_mismatches=3, k=3).match()
43+
counts = Matcher(query=mismatch_query, proteome_file=proteome_path,
44+
max_mismatches=3, k=3, counts_only=True).match()
45+
assert counts.columns == ['Query ID', 'Query Sequence', 'Mismatches', 'Count']
46+
plt.assert_frame_equal(_from_full(full), _from_counts(counts))
47+
48+
49+
def test_counts_parity_exact(proteome_path, exact_query):
50+
full = Matcher(query=exact_query, proteome_file=proteome_path,
51+
max_mismatches=0, k=5).match()
52+
counts = Matcher(query=exact_query, proteome_file=proteome_path,
53+
max_mismatches=0, k=5, counts_only=True).match()
54+
plt.assert_frame_equal(_from_full(full), _from_counts(counts))
55+
56+
57+
def test_counts_only_rejects_best_match(proteome_path, exact_query):
58+
with pytest.raises(ValueError):
59+
Matcher(query=exact_query, proteome_file=proteome_path,
60+
counts_only=True, best_match=True)

0 commit comments

Comments
 (0)