syntheogenesis / dee /core /align.py
Tengo Gzirishvili
Add pairwise sequence alignment (Needleman–Wunsch / Smith–Waterman)
91ed780
Raw
History Blame Contribute Delete
3.7 kB
"""Pairwise sequence alignment — Needleman–Wunsch (global) and Smith–Waterman
(local). numpy + stdlib only; no Biopython dependency so it's trivially testable.
Identity-based scoring (match / mismatch + linear gap) — enough for the
"compare two sequences" use case: visualise percent identity, mismatches, and
gaps for DNA or protein. O(n·m) DP, so we cap |a|·|b| to keep it snappy.
"""
from __future__ import annotations
from typing import Any, Dict
import numpy as np
# |a| * |b| ceiling (~1225 × 1225). Above this we ask the user to align a
# shorter region — the pure-Python traceback + DP fill stays sub-second below it.
MAX_CELLS = 1_500_000
def _clean(s: str) -> str:
return "".join(c for c in (s or "").upper() if not c.isspace())
def align(a: str, b: str, *, mode: str = "global",
match: int = 2, mismatch: int = -1, gap: int = -2) -> Dict[str, Any]:
"""Align sequences `a` and `b`.
mode: 'global' (Needleman–Wunsch) or 'local' (Smith–Waterman).
Returns aligned strings, a match midline, percent identity, score, gaps.
"""
a = _clean(a)
b = _clean(b)
if not a or not b:
raise ValueError("Both sequences must be non-empty.")
if len(a) * len(b) > MAX_CELLS:
raise ValueError(
f"Sequences too large to align in-browser (|a|×|b| > {MAX_CELLS:,}). "
"Align a shorter region."
)
local = (mode == "local")
n, m = len(a), len(b)
H = np.zeros((n + 1, m + 1), dtype=np.int32)
# traceback codes: 0 diag, 1 up (gap in b), 2 left (gap in a), 3 stop
T = np.zeros((n + 1, m + 1), dtype=np.int8)
if not local:
H[:, 0] = np.arange(n + 1) * gap
H[0, :] = np.arange(m + 1) * gap
T[1:, 0] = 1
T[0, 1:] = 2
best_score, best_i, best_j = 0, 0, 0
for i in range(1, n + 1):
ai = a[i - 1]
Hi, Hprev, Ti = H[i], H[i - 1], T[i]
for j in range(1, m + 1):
sc = match if ai == b[j - 1] else mismatch
cell = Hprev[j - 1] + sc
t = 0
up = Hprev[j] + gap
if up > cell:
cell, t = up, 1
left = Hi[j - 1] + gap
if left > cell:
cell, t = left, 2
if local and cell < 0:
cell, t = 0, 3
Hi[j] = cell
Ti[j] = t
if local and cell > best_score:
best_score, best_i, best_j = cell, i, j
if local:
score, i, j = best_score, best_i, best_j
else:
score, i, j = int(H[n, m]), n, m
out_a, out_b = [], []
while i > 0 or j > 0:
t = int(T[i, j])
if local and (H[i, j] == 0 or t == 3):
break
if t == 0:
out_a.append(a[i - 1]); out_b.append(b[j - 1]); i -= 1; j -= 1
elif t == 1:
out_a.append(a[i - 1]); out_b.append("-"); i -= 1
elif t == 2:
out_a.append("-"); out_b.append(b[j - 1]); j -= 1
else:
break
out_a.reverse(); out_b.reverse()
aa, bb = "".join(out_a), "".join(out_b)
cols = len(aa)
matches = sum(1 for x, y in zip(aa, bb) if x == y and x != "-")
identity = round(100.0 * matches / cols, 1) if cols else 0.0
gaps = aa.count("-") + bb.count("-")
midline = "".join(
"|" if (x == y and x != "-") else (" " if (x == "-" or y == "-") else ".")
for x, y in zip(aa, bb)
)
return {
"mode": "local" if local else "global",
"score": int(score),
"identity": identity,
"length": cols,
"matches": matches,
"gaps": gaps,
"aligned_a": aa,
"aligned_b": bb,
"midline": midline,
}