Spaces:
Sleeping
Sleeping
Download app/tools/msa_fallback.py from Samad14/bio-nexus-api: direct link, hf CLI and curl.
- Browser
- Download file 9.94 kB
-
https://huggingface.co/spaces/Samad14/bio-nexus-api/resolve/main/app/tools/msa_fallback.py
- Command line
-
hf download hf://spaces/Samad14/bio-nexus-api/app/tools/msa_fallback.py
-
curl -L -o msa_fallback.py https://huggingface.co/spaces/Samad14/bio-nexus-api/resolve/main/app/tools/msa_fallback.py
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) | |