Skip to content

Commit 31cbba3

Browse files
committed
fix: auto-optimize k for mismatch search instead of hardcoded k=5
Unspecified k defaulted to 5, silently missing matches (82/300 disagreed with a brute-force Hamming oracle; auto-k 0/300). Now derives k=max(2, floor(min_len/(max_mismatches+1))) via _auto_k(), gated on k_specified; counts path too. CLI -k default 5->0 so the CLI optimizes as well. Adds completeness regression test (brute-force property test + a k=5 silent-miss regression).
1 parent 804d8c4 commit 31cbba3

3 files changed

Lines changed: 189 additions & 7 deletions

File tree

pepmatch/matcher.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,12 +79,9 @@ def __init__(
7979
counts_only=False,
8080
):
8181

82-
if best_match and k == 0:
82+
if k == 0:
8383
self.k = 0
8484
self.k_specified = False
85-
elif k == 0:
86-
self.k = 5
87-
self.k_specified = False
8885
else:
8986
self.k = k
9087
self.k_specified = True
@@ -177,7 +174,8 @@ def match(self):
177174
discontinuous_df = pl.DataFrame()
178175

179176
if self.counts_only:
180-
df = self._counts_to_dataframe(self._search_counts(self.k, self.max_mismatches))
177+
k = self.k if self.k_specified else self._auto_k()
178+
df = self._counts_to_dataframe(self._search_counts(k, self.max_mismatches))
181179
if self.output_format == 'dataframe':
182180
return df
183181
return output_matches(df, self.output_format, self.output_name)
@@ -191,7 +189,8 @@ def match(self):
191189
elif self.best_match:
192190
linear_df = self.best_match_search()
193191
else:
194-
results = self._search(self.k, self.max_mismatches)
192+
k = self.k if self.k_specified else self._auto_k()
193+
results = self._search(k, self.max_mismatches)
195194
linear_df = self._to_dataframe(results)
196195

197196
if self.discontinuous_epitopes:
@@ -225,6 +224,15 @@ def indel_search(self):
225224
results = rs_indel_match(pepidx_path, self.query, self.max_indels)
226225
return self._to_dataframe(results, is_indels=True)
227226

227+
def _auto_k(self):
228+
"""Optimal k for a seed-based mismatch search (PEPMatch paper / pigeonhole):
229+
a length-L peptide with m mismatches must split into at least m+1 disjoint
230+
k-mers so one is guaranteed mismatch-free, i.e. k = floor(L / (m + 1)). Uses
231+
the shortest query peptide so the guarantee holds for every peptide; floored
232+
at 2 (the preprocessor minimum)."""
233+
min_len = min(len(seq) for _, seq in self.query)
234+
return max(2, min_len // (self.max_mismatches + 1))
235+
228236
def _pepidx_path(self, k):
229237
return os.path.join(
230238
self.preprocessed_files_path, f'{self.proteome_name}_{k}mers.pepidx'

pepmatch/shell.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ def run_matcher():
2222
parser.add_argument('-p', '--proteome_file', required=True)
2323
parser.add_argument('-m', '--max_mismatches', type=int, default=0)
2424
parser.add_argument('-i', '--max_indels', type=int, default=0)
25-
parser.add_argument('-k', '--kmer_size', type=int, default=5)
25+
parser.add_argument('-k', '--kmer_size', type=int, default=0)
2626
parser.add_argument('-P', '--preprocessed_files_path', default='.')
2727
parser.add_argument('-b', '--best_match', action='store_true', default=False)
2828
parser.add_argument('-f', '--output_format', default='csv')
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import random
2+
from pathlib import Path
3+
4+
import pytest
5+
from Bio import SeqIO
6+
from pepmatch import Matcher
7+
8+
# Guards the auto-k mismatch fix in matcher.py: when k is UNSPECIFIED, Matcher must
9+
# derive the pigeonhole-optimal k = max(2, min_len // (m + 1)) so that a seed-based
10+
# search returns the COMPLETE set of matches. The old code hardcoded k=5, which is
11+
# too large for short peptides / high m and silently dropped real matches. These
12+
# tests pin completeness (and soundness) against an independent brute-force oracle.
13+
14+
AMINO_ACIDS = 'ACDEFGHIKLMNPQRSTVWY'
15+
LENGTHS = (8, 9, 10, 11, 12, 15, 20)
16+
MISMATCHES = (1, 2, 3)
17+
18+
19+
@pytest.fixture
20+
def proteome_path() -> Path:
21+
return Path(__file__).parent / 'data' / 'proteome.fasta'
22+
23+
24+
def _load_proteins(proteome_path):
25+
"""Load (accession, sequence) pairs. The accession is exactly how PEPMatch labels
26+
a protein in its output ('<accession>.<version>' in the Protein ID column), so
27+
keying the oracle by accession lets us compare match LOCATIONS, not just sequences."""
28+
proteins = []
29+
for record in SeqIO.parse(str(proteome_path), 'fasta'):
30+
accession = record.id.split('|')[1] if '|' in record.id else record.id
31+
proteins.append((accession, str(record.seq).upper()))
32+
return proteins
33+
34+
35+
def _hamming(a, b):
36+
return sum(1 for x, y in zip(a, b) if x != y)
37+
38+
39+
def brute_force_mismatch(peptide, proteins, max_mismatches):
40+
"""Independent oracle: every proteome window of len(peptide) whose Hamming
41+
distance to the peptide is <= max_mismatches. No seeding, no k-mers -- just the
42+
raw definition of a mismatch match, so it cannot share a blind spot with the
43+
seed-based implementation. Returns {(accession, start_0based, window)}."""
44+
length = len(peptide)
45+
hits = set()
46+
for accession, seq in proteins:
47+
for i in range(len(seq) - length + 1):
48+
window = seq[i:i + length]
49+
if _hamming(peptide, window) <= max_mismatches:
50+
hits.add((accession, i, window))
51+
return hits
52+
53+
54+
def _matcher_rows(df):
55+
"""Accepted (non-null) matches from Matcher output, canonicalized into the
56+
oracle's key space: strip the sequence-version suffix from Protein ID to recover
57+
the accession, and convert the 1-based Index start to a 0-based offset. Returns a
58+
list (not a set) so callers can detect duplicate emission of the same match."""
59+
rows = []
60+
for row in df.iter_rows(named=True):
61+
if row['Matched Sequence'] is None:
62+
continue
63+
accession = row['Protein ID'].rsplit('.', 1)[0]
64+
rows.append((accession, row['Index start'] - 1, row['Matched Sequence']))
65+
return rows
66+
67+
68+
def _make_query(rng, proteins, length, m):
69+
"""Take a random proteome window of the given length and inject EXACTLY m
70+
mismatches at distinct positions, each replaced by a different amino acid. The
71+
source window is therefore always a real match at Hamming distance m."""
72+
candidates = [(acc, seq) for acc, seq in proteins if len(seq) >= length]
73+
accession, seq = rng.choice(candidates)
74+
start = rng.randrange(0, len(seq) - length + 1)
75+
window = list(seq[start:start + length])
76+
for p in rng.sample(range(length), m):
77+
window[p] = rng.choice([a for a in AMINO_ACIDS if a != window[p]])
78+
return ''.join(window)
79+
80+
81+
@pytest.mark.parametrize('m', MISMATCHES)
82+
def test_auto_k_search_is_complete_vs_brute_force(proteome_path, tmp_path, m):
83+
# Seeded so a red run reproduces exactly; seed differs per m to vary the corpus.
84+
rng = random.Random(20240717 + m)
85+
proteins = _load_proteins(proteome_path)
86+
seq_by_acc = dict(proteins)
87+
88+
with_matches = 0
89+
for length in LENGTHS:
90+
if length <= m:
91+
continue
92+
for _ in range(12):
93+
peptide = _make_query(rng, proteins, length, m)
94+
expected = brute_force_mismatch(peptide, proteins, m)
95+
if not expected:
96+
continue # a query with no oracle match cannot exercise completeness
97+
98+
df = Matcher(
99+
query=[peptide],
100+
proteome_file=str(proteome_path),
101+
max_mismatches=m,
102+
preprocessed_files_path=str(tmp_path),
103+
).match()
104+
105+
length = len(peptide)
106+
rows = _matcher_rows(df)
107+
# A reported hit whose Matched Sequence does not equal the protein's own
108+
# window at that offset is the pre-existing concatenated-index boundary
109+
# artifact (a window running off the end of one protein into the next). That
110+
# is a soundness quirk unrelated to k selection, so it is excluded from the
111+
# in-protein comparison below; it can only ever ADD matches, never drop one.
112+
in_protein = [
113+
t for t in rows if seq_by_acc[t[0]][t[1]:t[1] + length] == t[2]
114+
]
115+
actual = set(in_protein)
116+
117+
# COMPLETENESS (the contract the fix guards): every real proteome window
118+
# within m mismatches must be reported -- nothing silently dropped. Set
119+
# equality also pins SOUNDNESS: no genuine window is over-reported.
120+
assert actual == expected, (
121+
f'auto-k disagreed with brute force for {peptide!r} (m={m}); '
122+
f'dropped={sorted(expected - actual)} spurious={sorted(actual - expected)}'
123+
)
124+
# No duplicate emission of a genuine match (a bare set would absorb dupes).
125+
assert len(in_protein) == len(expected), (
126+
f'auto-k emitted {len(in_protein)} in-protein rows but oracle has '
127+
f'{len(expected)} distinct matches for {peptide!r} (m={m})'
128+
)
129+
with_matches += 1
130+
131+
# The suite is only meaningful on peptides that actually have matches; refuse to
132+
# pass vacuously if the corpus somehow degenerated to all-misses.
133+
assert with_matches >= 50
134+
135+
136+
def test_auto_k_recovers_match_that_hardcoded_k5_dropped(proteome_path, tmp_path):
137+
# Focused regression for the exact failure mode the matcher.py fix repairs.
138+
#
139+
# 'MSKRALIIA' is the proteome window 'MSKRVLIIA' (accession Q02004, index 0)
140+
# carrying a single mismatch at the centre. A length-9 / 1-mismatch query needs
141+
# two disjoint seeds -- i.e. k <= 4 -- for the pigeonhole guarantee to hold.
142+
# The OLD matcher hardcoded k=5 whenever k was unspecified: with a 5-mer seed no
143+
# sub-window of the query avoids the central mismatch, so the seed lookup finds
144+
# no candidate and this real match is SILENTLY DROPPED. The fix derives
145+
# k = max(2, 9 // (1 + 1)) = 4, which recovers it.
146+
proteins = _load_proteins(proteome_path)
147+
peptide = 'MSKRALIIA'
148+
source_match = ('Q02004', 0, 'MSKRVLIIA')
149+
150+
expected = brute_force_mismatch(peptide, proteins, 1)
151+
assert source_match in expected # the oracle confirms this is a genuine match
152+
153+
auto_k = set(_matcher_rows(
154+
Matcher(
155+
query=[peptide],
156+
proteome_file=str(proteome_path),
157+
max_mismatches=1,
158+
preprocessed_files_path=str(tmp_path),
159+
).match()
160+
))
161+
hardcoded_k5 = set(_matcher_rows(
162+
Matcher(
163+
query=[peptide],
164+
proteome_file=str(proteome_path),
165+
max_mismatches=1,
166+
k=5,
167+
preprocessed_files_path=str(tmp_path),
168+
).match()
169+
))
170+
171+
# auto-k is complete and finds the match; the old hardcoded k=5 drops it.
172+
assert source_match in auto_k
173+
assert auto_k == expected
174+
assert source_match not in hardcoded_k5

0 commit comments

Comments
 (0)