bio-nexus-api / app /tools /msa_fallback.py
Samad14's picture
Upload folder using huggingface_hub
2906f8a verified
Raw History Blame Contribute Delete
9.94 kB
"""Deterministic in-process progressive MSA fallback (Biopython).
This module is used only when the preferred local/remote MSA engines are not
available. It is intentionally labelled as a fallback: it is not presented as
Clustal Omega or MAFFT parity. Every input taxon and residue is validated and
preserved, and guide-tree construction fails explicitly rather than silently
substituting a fabricated star topology.
"""
from __future__ import annotations
import re
_SAFE_ID = re.compile(r"^[A-Za-z0-9_.-]+$")
_DNA_IUPAC = set("ACGTRYSWKMBDHVN")
_PROTEIN_IUPAC = set("ACDEFGHIKLMNPQRSTVWYBXZJUO")
def _validate_sequences(
sequences: list[tuple[str, str]], stype: str
) -> list[tuple[str, str]]:
if stype not in ("protein", "dna"):
raise ValueError("stype must be 'protein' or 'dna'")
if not sequences:
raise ValueError("No sequences to align")
alphabet = _PROTEIN_IUPAC if stype == "protein" else _DNA_IUPAC
seen: set[str] = set()
clean: list[tuple[str, str]] = []
for index, record in enumerate(sequences):
if not isinstance(record, (tuple, list)) or len(record) != 2:
raise ValueError(f"Sequence record {index + 1} must be an (id, sequence) pair")
sid = str(record[0] or "").strip()
seq = "".join(str(record[1] or "").split()).upper()
if not sid:
raise ValueError(f"Sequence record {index + 1} is missing an identifier")
if not _SAFE_ID.fullmatch(sid):
raise ValueError(
f"Sequence identifier {sid!r} is not Newick/FASTA safe; "
"use letters, digits, underscore, dot or hyphen"
)
if sid in seen:
raise ValueError(f"Duplicate sequence identifier: {sid}")
if not seq:
raise ValueError(f"Sequence {sid!r} is empty")
bad = sorted(set(seq) - alphabet)
if bad:
raise ValueError(
f"Sequence {sid!r} contains characters invalid for {stype}: {', '.join(bad)}"
)
seen.add(sid)
clean.append((sid, seq))
return clean
def _aligner(stype: str):
from Bio.Align import PairwiseAligner
from Bio.Align import substitution_matrices
aligner = PairwiseAligner()
aligner.mode = "global"
if stype == "protein":
aligner.substitution_matrix = substitution_matrices.load("BLOSUM62")
aligner.open_gap_score = -11.0
aligner.extend_gap_score = -1.0
else:
aligner.substitution_matrix = None
aligner.match_score = 2.0
aligner.mismatch_score = -1.0
aligner.open_gap_score = -2.0
aligner.extend_gap_score = -0.5
return aligner
def _pairwise(aligner, s1: str, s2: str) -> tuple[str, str]:
"""Return the two gapped rows from the best global pairwise alignment."""
alignments = aligner.align(s1, s2)
if len(alignments) == 0:
raise ValueError("Pairwise aligner returned no alignment")
best = alignments[0]
g1, g2 = str(best[0]), str(best[1])
if len(g1) != len(g2):
raise ValueError("Pairwise aligner produced rows of unequal length")
if g1.replace("-", "") != s1 or g2.replace("-", "") != s2:
raise ValueError("Pairwise alignment failed residue-preservation check")
return g1, g2
def _identity(g1: str, g2: str) -> float:
if len(g1) != len(g2):
raise ValueError("Identity requires aligned rows of equal length")
aligned = sum(1 for a, b in zip(g1, g2) if a != "-" and b != "-")
if aligned == 0:
return 0.0
matches = sum(1 for a, b in zip(g1, g2) if a == b and a != "-")
return matches / aligned
def _upgma_newick(labels: list[str], dist: list[list[float]]) -> str:
"""Build an ultrametric UPGMA tree from a symmetric distance matrix."""
n = len(labels)
if n == 0:
raise ValueError("Cannot build a guide tree with no taxa")
if len(dist) != n or any(len(row) != n for row in dist):
raise ValueError("Distance matrix dimensions do not match taxon count")
if len(labels) != len(set(labels)):
raise ValueError("Guide-tree taxon identifiers must be unique")
for label in labels:
_safe_label(label)
for i in range(n):
if abs(float(dist[i][i])) > 1e-12:
raise ValueError("Distance-matrix diagonal must be zero")
for j in range(n):
value = float(dist[i][j])
if value < 0:
raise ValueError("Distances must be non-negative")
if abs(value - float(dist[j][i])) > 1e-12:
raise ValueError("Distance matrix must be symmetric")
d: dict[int, dict[int, float]] = {
i: {j: float(dist[i][j]) for j in range(n)} for i in range(n)
}
size = {i: 1 for i in range(n)}
height = {i: 0.0 for i in range(n)}
names = {i: labels[i] for i in range(n)}
active: set[int] = set(range(n))
next_id = n
while len(active) > 1:
ordered = sorted(active)
candidates = [
(d[i][j], i, j)
for pos, i in enumerate(ordered)
for j in ordered[pos + 1 :]
]
if not candidates:
raise ValueError("UPGMA could not find a pair of active clusters")
distance, i, j = min(candidates, key=lambda item: (item[0], item[1], item[2]))
new_height = distance / 2.0
branch_i = max(0.0, new_height - height[i])
branch_j = max(0.0, new_height - height[j])
merged = next_id
next_id += 1
si, sj = size[i], size[j]
d[merged] = {}
for k in ordered:
if k in (i, j):
continue
merged_distance = (d[i][k] * si + d[j][k] * sj) / (si + sj)
d[merged][k] = merged_distance
d[k][merged] = merged_distance
names[merged] = (
f"({names[i]}:{branch_i:.6f},{names[j]}:{branch_j:.6f})"
)
size[merged] = si + sj
height[merged] = new_height
active.remove(i)
active.remove(j)
active.add(merged)
root = next(iter(active))
return names[root] + ";"
def _safe_label(label: str) -> str:
if not label or not _SAFE_ID.fullmatch(label):
raise ValueError(f"Unsafe Newick taxon identifier: {label!r}")
return label
def _star_newick(labels: list[str]) -> str:
if not labels:
raise ValueError("Cannot build a star tree with no taxa")
leaves = ",".join(_safe_label(label) for label in labels)
return f"({leaves});"
def _expand(row: str, c_aln: str) -> str:
"""Transfer consensus gaps into one existing profile row."""
out: list[str] = []
consumed = 0
for ch in c_aln:
if ch == "-":
out.append("-")
else:
if consumed >= len(row):
raise ValueError("Consensus-to-profile mapping consumed too many columns")
out.append(row[consumed])
consumed += 1
if consumed != len(row):
raise ValueError("Consensus-to-profile mapping did not consume every existing column")
return "".join(out)
def _consensus(rows: list[str]) -> str:
if not rows:
raise ValueError("Cannot compute a consensus from an empty profile")
lengths = {len(row) for row in rows}
if len(lengths) != 1:
raise ValueError("Profile rows have unequal alignment lengths")
length = len(rows[0])
cons: list[str] = []
for col in range(length):
counts: dict[str, int] = {}
for row in rows:
char = row[col]
if char != "-":
counts[char] = counts.get(char, 0) + 1
if not counts:
raise ValueError("Progressive profile contains an all-gap column")
# Deterministic tie break by residue symbol.
cons.append(min(counts, key=lambda char: (-counts[char], char)))
return "".join(cons)
def progressive_msa(
sequences: list[tuple[str, str]], stype: str = "protein"
) -> tuple[str, str]:
"""Return ``(aligned_fasta, guide_tree_newick)`` for the fallback method.
This simple consensus-progressive algorithm is a resilience path, not a
substitute validation target for MAFFT/Clustal Omega. The result is accepted
only if every original sequence is represented exactly once and all aligned
rows have the same length.
"""
clean = _validate_sequences(sequences, stype)
ids = [record[0] for record in clean]
seqs = [record[1] for record in clean]
n = len(seqs)
aligner = _aligner(stype)
if n == 1:
fasta = _to_fasta([(ids[0], seqs[0])])
return fasta, _star_newick(ids)
dist = [[0.0] * n for _ in range(n)]
for i in range(n):
for j in range(i + 1, n):
g1, g2 = _pairwise(aligner, seqs[i], seqs[j])
dist[i][j] = dist[j][i] = 1.0 - _identity(g1, g2)
newick = _upgma_newick(ids, dist)
rows = [seqs[0]]
consensus = seqs[0]
for seq in seqs[1:]:
consensus_aln, seq_aln = _pairwise(aligner, consensus, seq)
rows = [_expand(row, consensus_aln) for row in rows]
rows.append(seq_aln)
if len({len(row) for row in rows}) != 1:
raise ValueError("Progressive MSA produced rows of unequal length")
consensus = _consensus(rows)
for original, row in zip(seqs, rows):
if row.replace("-", "") != original:
raise ValueError("Progressive MSA failed residue-preservation check")
if len({len(row) for row in rows}) != 1:
raise ValueError("Progressive MSA final rows have unequal lengths")
fasta = _to_fasta([(ids[index], rows[index]) for index in range(n)])
return fasta, newick
def _to_fasta(seqs: list[tuple[str, str]], width: int = 80) -> str:
lines: list[str] = []
for sid, sseq in seqs:
_safe_label(sid)
lines.append(f">{sid}")
for i in range(0, len(sseq), width):
lines.append(sseq[i : i + width])
return "\n".join(lines)