Algorithm Search⌘K

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
de_bruijn_graph.pyRawGitHub
# 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

Read the full page about de Bruijn graph