Skip to content

Commit eeedfbd

Browse files
authored
Make greedy merge deterministic: sort candidates by overlap size (#154)
Rewrite greedy_merge_helper to score all candidate merges, sort by overlap size (descending) then read count, and greedily accept non- conflicting merges (each sequence participates in at most one merge per round). This makes the output reproducible and tends toward better assemblies by preferring high-confidence joins. Three new tests: - Determinism across 20 random input shuffles (greedy_merge level) - Determinism across 10 random shuffles (iterative_overlap_assembly) - Verification that larger overlaps are preferred over smaller ones Fixes #153, also covers #148
1 parent 2a1ad0d commit eeedfbd

3 files changed

Lines changed: 134 additions & 25 deletions

File tree

isovar/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
# See the License for the specific language governing permissions and
1111
# limitations under the License.
1212

13-
__version__ = "1.4.10"
13+
__version__ = "1.4.11"
1414

1515

1616
from .allele_read import AlleleRead

isovar/assembly.py

Lines changed: 40 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -22,34 +22,51 @@ def greedy_merge_helper(
2222
variant_sequences,
2323
min_overlap_size=MIN_VARIANT_SEQUENCE_ASSEMBLY_OVERLAP_SIZE):
2424
"""
25+
Score all valid pairwise merges by overlap size and greedily accept
26+
the best non-conflicting merges (each sequence participates in at most
27+
one merge per round). This makes the output deterministic regardless
28+
of input order.
29+
2530
Returns a list of merged VariantSequence objects, and True if any
2631
were successfully merged.
2732
"""
28-
merged_variant_sequences = {}
29-
merged_any = False
30-
31-
# here we'll keep track of sequences that haven't been merged yet, and add them in at the end
32-
unmerged_variant_sequences = set(variant_sequences)
33+
candidates = []
3334
for i in range(len(variant_sequences)):
34-
sequence1 = variant_sequences[i]
35-
# it works to loop over the triangle (i+1 onwards) because combine() tries flipping the
36-
# arguments if sequence1 is on the right of sequence2
3735
for j in range(i + 1, len(variant_sequences)):
38-
sequence2 = variant_sequences[j]
39-
combined = sequence1.combine(sequence2, min_overlap_size=min_overlap_size)
40-
if combined is None:
41-
continue
42-
if combined.sequence in merged_variant_sequences:
43-
existing = merged_variant_sequences[combined.sequence]
44-
# the existing VariantSequence and the newly merged
45-
# VariantSequence should differ only in which reads support them
46-
combined = combined.add_reads(existing.reads)
47-
merged_variant_sequences[combined.sequence] = combined
48-
unmerged_variant_sequences.discard(sequence1)
49-
unmerged_variant_sequences.discard(sequence2)
50-
merged_any = True
51-
result = list(merged_variant_sequences.values()) + list(unmerged_variant_sequences)
52-
return result, merged_any
36+
combined = variant_sequences[i].combine(
37+
variant_sequences[j], min_overlap_size=min_overlap_size)
38+
if combined is not None:
39+
overlap = (
40+
len(variant_sequences[i])
41+
+ len(variant_sequences[j])
42+
- len(combined))
43+
candidates.append((overlap, len(combined.reads), i, j, combined))
44+
45+
if not candidates:
46+
return list(variant_sequences), False
47+
48+
# largest overlap first, then most reads, then indices for determinism
49+
candidates.sort(key=lambda c: (-c[0], -c[1], c[2], c[3]))
50+
51+
used = set()
52+
merged_variant_sequences = {}
53+
for overlap, n_reads, i, j, combined in candidates:
54+
if i in used or j in used:
55+
continue
56+
used.add(i)
57+
used.add(j)
58+
if combined.sequence in merged_variant_sequences:
59+
existing = merged_variant_sequences[combined.sequence]
60+
combined = combined.add_reads(existing.reads)
61+
merged_variant_sequences[combined.sequence] = combined
62+
63+
unmerged = [
64+
variant_sequences[k]
65+
for k in range(len(variant_sequences))
66+
if k not in used
67+
]
68+
result = list(merged_variant_sequences.values()) + unmerged
69+
return result, True
5370

5471

5572
def greedy_merge(

tests/test_assembly.py

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
# See the License for the specific language governing permissions and
1111
# limitations under the License.
1212

13+
import random
1314
from time import time
1415

1516
from isovar.read_collector import ReadCollector
@@ -25,7 +26,7 @@
2526
from pyensembl import ensembl_grch38
2627
from varcode import Variant
2728

28-
from .common import eq_
29+
from .common import eq_
2930
from .testing_helpers import load_bam
3031

3132

@@ -330,3 +331,94 @@ def test_assembly_1_sequence():
330331
suffix="GGG",
331332
reads={"1"})
332333
eq_(iterative_overlap_assembly([vs]), [vs])
334+
335+
336+
def test_greedy_merge_deterministic_across_input_orders():
337+
"""
338+
Verify that greedy_merge produces the same assembled sequences
339+
regardless of input order. Regression test for #153, also covers #148.
340+
"""
341+
variant_sequences = [
342+
VariantSequence(prefix="AAAAAA", alt="CC", suffix="TTTT", reads={"r1"}),
343+
VariantSequence(prefix="AAAA", alt="CC", suffix="TTTTGG", reads={"r2"}),
344+
VariantSequence(prefix="GGAAAAAA", alt="CC", suffix="TT", reads={"r3"}),
345+
VariantSequence(prefix="AAA", alt="CC", suffix="TTTTGGCC", reads={"r4"}),
346+
VariantSequence(prefix="CCGGAAAAAA", alt="CC", suffix="T", reads={"r5"}),
347+
]
348+
349+
reference_result = greedy_merge(variant_sequences, min_overlap_size=1)
350+
reference_sequences = sorted(r.sequence for r in reference_result)
351+
reference_all_reads = set().union(*(r.reads for r in reference_result))
352+
353+
rng = random.Random(42)
354+
for trial in range(20):
355+
shuffled = list(variant_sequences)
356+
rng.shuffle(shuffled)
357+
result = greedy_merge(shuffled, min_overlap_size=1)
358+
result_sequences = sorted(r.sequence for r in result)
359+
result_all_reads = set().union(*(r.reads for r in result))
360+
assert result_sequences == reference_sequences, \
361+
"Trial %d: different sequences from shuffled input.\nRef: %s\nGot: %s" % (
362+
trial, reference_sequences, result_sequences)
363+
assert result_all_reads == reference_all_reads, \
364+
"Trial %d: different reads from shuffled input.\nRef: %s\nGot: %s" % (
365+
trial, reference_all_reads, result_all_reads)
366+
367+
368+
def test_greedy_merge_prefers_larger_overlap():
369+
"""
370+
When a sequence could merge with two partners, the larger overlap
371+
should be chosen. This tests the sorting logic in greedy_merge_helper.
372+
"""
373+
# A overlaps B by 3 bases, A overlaps C by 6 bases.
374+
# A should merge with C (larger overlap) not B.
375+
vs_a = VariantSequence(prefix="AAAAAA", alt="X", suffix="TTTTTT", reads={"a"})
376+
vs_b = VariantSequence(prefix="AAA", alt="X", suffix="TTTTTTGGGGGG", reads={"b"})
377+
vs_c = VariantSequence(prefix="AAAAAA", alt="X", suffix="TTTTTTCCCCCC", reads={"c"})
378+
379+
result = greedy_merge([vs_a, vs_b, vs_c], min_overlap_size=1)
380+
381+
# A+C should merge (overlap = 6+6+1=13 bases out of 13+13=26)
382+
# leaving B unmerged (or merging in a subsequent round if compatible)
383+
merged_reads = set()
384+
for r in result:
385+
merged_reads.update(r.reads)
386+
assert merged_reads == {"a", "b", "c"}, \
387+
"All reads should be accounted for, got %s" % merged_reads
388+
389+
# The longest assembled sequence should contain C's long suffix
390+
longest = max(result, key=len)
391+
assert "CCCCCC" in longest.sequence, \
392+
"Expected the larger overlap (with C's CCCCCC suffix) to be preferred"
393+
394+
395+
def test_iterative_assembly_deterministic_across_input_orders():
396+
"""
397+
End-to-end test: iterative_overlap_assembly should produce
398+
identical results regardless of input ordering.
399+
"""
400+
original_prefix = "ACTGAACCTTGG"
401+
original_allele = "CC"
402+
original_suffix = "GGAAGGAAGGAA"
403+
404+
subsequences = [
405+
VariantSequence(
406+
prefix=original_prefix[i:],
407+
alt=original_allele,
408+
suffix=original_suffix[:-j] if j > 0 else original_suffix,
409+
reads={str(i) + "_" + str(j)})
410+
for i in range(6)
411+
for j in range(6)
412+
]
413+
414+
reference = iterative_overlap_assembly(subsequences, min_overlap_size=2)
415+
reference_sequences = sorted(r.sequence for r in reference)
416+
417+
rng = random.Random(123)
418+
for trial in range(10):
419+
shuffled = list(subsequences)
420+
rng.shuffle(shuffled)
421+
result = iterative_overlap_assembly(shuffled, min_overlap_size=2)
422+
result_sequences = sorted(r.sequence for r in result)
423+
assert result_sequences == reference_sequences, \
424+
"Trial %d: different assembly from shuffled input" % trial

0 commit comments

Comments
 (0)