de Bruijn graph in Python
Graph whose vertices are (k-1)-mers and whose edges are k-mers of the reads.
- File
de_bruijn_graph.py, 132 lines- Level
- I0 not checked yet
- Toolchain
- Python 3.14, pytest 9
- Test framework
- pytest
- Run the tests
python3 -m pytest
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/de-bruijn-graph/. The authors are listed in prov/ZCP03V.jsonl.
"""A de Bruijn graph of order K over strings, stored as hash tables of letters."""
from itertools import product
from typing import Dict, Iterable, List, Optional, Sequence
class DeBruijnGraph:
"""Vertices are (K-1)-mers; each distinct K-mer x is an edge x[:-1] -> x[1:].
Precondition: K >= 2. Edge multiplicities count the copies added.
Cost: add_kmer O(K) expected, add_read O(|r| K) expected,
successors and predecessors O(sigma K), spell O(K + w).
"""
def __init__(self, k: int) -> None:
if k < 2:
raise ValueError("need K >= 2")
self.k = k
self.out: Dict[str, Dict[str, int]] = {}
self.inn: Dict[str, Dict[str, int]] = {}
def add_kmer(self, x: str) -> None:
k = self.k
if len(x) != k:
raise ValueError("a K-mer must have length K")
# @line L1
u, v = x[: k - 1], x[1:]
# @line L2
for w in (u, v):
self.out.setdefault(w, {})
self.inn.setdefault(w, {})
# @line L3
self.out[u][x[k - 1]] = self.out[u].get(x[k - 1], 0) + 1
# @line L4
self.inn[v][x[0]] = self.inn[v].get(x[0], 0) + 1
def add_read(self, r: str) -> None:
k = self.k
# @line L5
for i in range(0, len(r) - k + 1):
self.add_kmer(r[i : i + k])
def successors(self, u: str) -> List[str]:
# @line L6
return [u[1:] + c for c in self.out.get(u, {})]
def predecessors(self, v: str) -> List[str]:
# @line L7
return [a + v[:-1] for a in self.inn.get(v, {})]
def spell(self, walk: Sequence[str]) -> str:
"""The string spelled by a walk; raises ValueError if it is not a walk."""
if not walk:
raise ValueError("a walk has at least one vertex")
for t in range(1, len(walk)):
if walk[t] not in self.successors(walk[t - 1]):
raise ValueError("not a walk")
# @line L8
s = [walk[0]]
# @line L9
for t in range(1, len(walk)):
s.append(walk[t][-1])
# @line L10
return "".join(s)
def vertices(self) -> List[str]:
return list(self.out)
def edges(self) -> Dict[str, int]:
"""Each distinct K-mer with its multiplicity."""
return {u + c: m for u, row in self.out.items() for c, m in row.items()}
def eulerian_walk(self) -> Optional[List[str]]:
"""A walk using each distinct edge once, or None (Hierholzer, iterative)."""
edges = self.edges()
if not edges:
return None
outdeg = {u: len(row) for u, row in self.out.items()}
indeg = {v: len(row) for v, row in self.inn.items()}
starts = [u for u in self.out if outdeg[u] - indeg[u] == 1]
ends = [u for u in self.out if indeg[u] - outdeg[u] == 1]
balanced = all(abs(outdeg[u] - indeg[u]) <= 1 for u in self.out)
if not balanced or len(starts) > 1 or len(ends) > 1 or len(starts) != len(ends):
return None
start = starts[0] if starts else next(u for u in self.out if outdeg[u] > 0)
nxt = {u: sorted(row) for u, row in self.out.items()}
pos = {u: 0 for u in self.out}
stack, path = [start], []
while stack:
u = stack[-1]
if pos[u] < len(nxt[u]):
c = nxt[u][pos[u]]
pos[u] += 1
stack.append(u[1:] + c)
else:
path.append(stack.pop())
path.reverse()
if len(path) != len(edges) + 1:
return None
return path
def check_invariant(self, kmers: Optional[Iterable[str]] = None) -> None:
k = self.k
assert set(self.out) == set(self.inn)
for w in self.out:
assert len(w) == k - 1
for u, row in self.out.items():
for c, m in row.items():
assert m >= 1
v = u[1:] + c
assert v in self.inn and self.inn[v].get(u[0]) == m
assert sum(sum(r.values()) for r in self.out.values()) == sum(
sum(r.values()) for r in self.inn.values()
)
if kmers is not None:
count: Dict[str, int] = {}
for x in kmers:
count[x] = count.get(x, 0) + 1
assert self.edges() == count
ends = {x[:-1] for x in count} | {x[1:] for x in count}
assert set(self.out) == ends
def complete_graph(alphabet: str, k: int) -> DeBruijnGraph:
"""The complete de Bruijn graph with all K-mers over the alphabet as edges."""
g = DeBruijnGraph(k)
for t in product(alphabet, repeat=k):
g.add_kmer("".join(t))
return g
test_de_bruijn_graph.py 125 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/de-bruijn-graph/. The authors are listed in prov/ZCP03V.jsonl.
import itertools
import random
from typing import List
import pytest
from de_bruijn_graph import DeBruijnGraph, complete_graph
def kmers_of(reads: List[str], k: int) -> List[str]:
return [r[i : i + k] for r in reads for i in range(len(r) - k + 1)]
def test_example():
g = DeBruijnGraph(3)
for r in ("ACGTCG", "GTCGAT"):
g.add_read(r)
g.check_invariant(kmers_of(["ACGTCG", "GTCGAT"], 3))
assert g.edges() == {"ACG": 1, "CGT": 1, "GTC": 2, "TCG": 2, "CGA": 1, "GAT": 1}
assert sorted(g.successors("CG")) == ["GA", "GT"]
assert sorted(g.predecessors("CG")) == ["AC", "TC"]
assert g.successors("AT") == [] and g.predecessors("AC") == []
walk = ["AC", "CG", "GT", "TC", "CG", "GA", "AT"]
assert g.spell(walk) == "ACGTCGAT"
assert g.spell(["AC", "CG", "GA", "AT"]) == "ACGAT"
assert g.spell(g.eulerian_walk()) == "ACGTCGAT"
b = complete_graph("01", 3)
circuit = ["00", "00", "01", "10", "01", "11", "11", "10", "00"]
assert b.spell(circuit) == "0001011100"
def test_edge_cases():
with pytest.raises(ValueError):
DeBruijnGraph(1)
g = DeBruijnGraph(4)
g.add_read("abc")
g.add_read("")
assert g.edges() == {} and g.vertices() == []
assert g.eulerian_walk() is None
g.add_read("aaaaa")
assert g.edges() == {"aaaa": 2}
assert g.successors("aaa") == ["aaa"]
assert g.spell(["aaa", "aaa", "aaa"]) == "aaaaa"
with pytest.raises(ValueError):
g.spell(["aaa", "aab"])
with pytest.raises(ValueError):
g.add_kmer("ab")
g.check_invariant(["aaaa", "aaaa"])
def is_de_bruijn(s: str, alphabet: str, n: int) -> bool:
t = s + s[: n - 1]
return len(s) == len(alphabet) ** n and len({t[i : i + n] for i in range(len(s))}) == len(s)
def test_complete_graphs_give_de_bruijn_sequences():
for alphabet in ("a", "ab", "abc"):
for k in range(2, 6 if len(alphabet) < 3 else 5):
g = complete_graph(alphabet, k)
g.check_invariant()
assert len(g.vertices()) == len(alphabet) ** (k - 1)
for u in g.vertices():
assert len(g.successors(u)) == len(alphabet) == len(g.predecessors(u))
walk = g.eulerian_walk()
s = g.spell(walk)
assert walk[0] == walk[-1]
assert is_de_bruijn(s[: len(alphabet) ** k], alphabet, k)
def test_exhaustive_small_spelling():
# Every string of length <= 6 over {a, b} is spelled by the walk of its
# K-mers, and every walk of length 3 spells a string with those K-mers.
for k in (2, 3):
for n in range(k, 7):
for t in itertools.product("ab", repeat=n):
s = "".join(t)
g = DeBruijnGraph(k)
g.add_read(s)
g.check_invariant(kmers_of([s], k))
walk = [s[i : i + k - 1] for i in range(n - k + 2)]
assert g.spell(walk) == s
def test_random_against_brute_force():
rng = random.Random(1946)
for trial in range(700):
sigma = rng.choice((1, 2, 3))
alphabet = "abc"[:sigma]
k = rng.randint(2, 5)
reads = [
"".join(rng.choice(alphabet) for _ in range(rng.randint(0, 12)))
for _ in range(rng.randint(1, 4))
]
g = DeBruijnGraph(k)
for r in reads:
g.add_read(r)
km = kmers_of(reads, k)
g.check_invariant(km)
distinct = set(km)
vertices = {x[:-1] for x in distinct} | {x[1:] for x in distinct}
for u in vertices:
assert sorted(g.successors(u)) == sorted(x[1:] for x in distinct if x[:-1] == u)
assert sorted(g.predecessors(u)) == sorted(x[:-1] for x in distinct if x[1:] == u)
if not vertices:
continue
# A random walk spells a string whose K-mers are the walk's edges.
u = rng.choice(sorted(vertices))
walk = [u]
for _ in range(rng.randint(0, 8)):
nxt = g.successors(walk[-1])
if not nxt:
break
walk.append(rng.choice(nxt))
s = g.spell(walk)
assert len(s) == k - 1 + len(walk) - 1
assert [s[i : i + k] for i in range(len(walk) - 1)] == [
walk[i] + walk[i + 1][-1] for i in range(len(walk) - 1)
]
ew = g.eulerian_walk()
if ew is not None:
spelled = g.spell(ew)
assert sorted(spelled[i : i + k] for i in range(len(spelled) - k + 1)) == sorted(distinct)
What the next level needs
- the tests did not run in this build