Spaces:
Running
Running
| """Which residues evolution has refused to change — computed, not predicted. | |
| WHY THIS EXISTS ALONGSIDE ESM-2 | |
| ------------------------------- | |
| ESM-2 gives a learned opinion about whether a substitution looks plausible. | |
| Conservation across real homologs gives an observed fact: in 40 orthologs, | |
| this position is serine 40 times. Those are different kinds of evidence and | |
| they fail differently — the model is weakest exactly where the audit says it | |
| is (membrane proteins, disordered regions, multi-domain assemblies), and a | |
| frequency count is unaffected by any of that. | |
| So this complements the scorer rather than duplicating it, which is why the | |
| audit rated it High: it is the cheapest real signal for "don't mutate this | |
| residue", and unlike a prediction it can be checked by counting. | |
| TWO PIECES | |
| ---------- | |
| `align_many` builds a progressive multiple alignment on top of the pairwise | |
| aligner that already exists (`align.py`), anchored on the longest sequence. | |
| This is the classic progressive approach and it is approximate: a true | |
| simultaneous MSA is exponential, and every practical tool approximates. It is | |
| honest about being an approximation rather than presenting itself as ground | |
| truth. | |
| `score` then counts. Shannon entropy per column, the most common residue, and | |
| the fraction of sequences that agree. No model, no training, no weights. | |
| THE HONEST LIMIT, WHICH IS ABOUT INPUT NOT ALGORITHM | |
| ---------------------------------------------------- | |
| Conservation is only as meaningful as the homolog set. Forty sequences that | |
| are 99% identical to each other say nothing — they are one sequence counted | |
| forty times. The result therefore reports the diversity of the input, and | |
| refuses to present a confident conservation call on a set with no spread. | |
| """ | |
| from __future__ import annotations | |
| import math | |
| import re | |
| from collections import Counter | |
| from typing import Any, Dict, List, Optional, Sequence | |
| from dee.core.align import align | |
| GAP = "-" | |
| # Below this many sequences a "conserved" call means almost nothing. | |
| MIN_SEQS = 3 | |
| # Mean pairwise identity above which the set is effectively one sequence. | |
| REDUNDANT_ABOVE = 95.0 | |
| def _clean(s: str) -> str: | |
| return re.sub(r"[^A-Za-z*]", "", s or "").upper() | |
| def align_many(sequences: Sequence[str], | |
| names: Optional[Sequence[str]] = None) -> Dict[str, Any]: | |
| """Progressive multiple alignment, anchored on the longest sequence. | |
| Each sequence is aligned pairwise to the anchor and its gaps merged into a | |
| common frame. Approximate by construction — stated in the result so no | |
| caller mistakes it for a simultaneous optimum. | |
| """ | |
| seqs = [_clean(s) for s in (sequences or [])] | |
| keep = [(i, s) for i, s in enumerate(seqs) if s] | |
| if len(keep) < 2: | |
| return {"ok": False, "error": "Need at least two non-empty sequences."} | |
| labels = list(names or []) | |
| def label(i: int) -> str: | |
| return str(labels[i]) if i < len(labels) and labels[i] else f"seq{i + 1}" | |
| anchor_i, anchor = max(keep, key=lambda kv: len(kv[1])) | |
| # Columns the anchor must gain, keyed by the anchor index they precede. | |
| inserts: Dict[int, int] = {} | |
| pairs: List[Dict[str, Any]] = [] | |
| for i, s in keep: | |
| if i == anchor_i: | |
| continue | |
| try: | |
| r = align(anchor, s, mode="global") | |
| except ValueError as exc: | |
| return {"ok": False, "kind": "too_large", "error": str(exc), | |
| "next": "Align shorter sequences, or fewer of them."} | |
| pairs.append({"i": i, "a": r["aligned_a"], "b": r["aligned_b"], | |
| "identity": r["identity"]}) | |
| pos = 0 | |
| run = 0 | |
| for ca in r["aligned_a"]: | |
| if ca == GAP: | |
| run += 1 | |
| else: | |
| if run: | |
| inserts[pos] = max(inserts.get(pos, 0), run) | |
| run = 0 | |
| pos += 1 | |
| if run: | |
| inserts[pos] = max(inserts.get(pos, 0), run) | |
| def expand(aligned_a: str, aligned_b: str) -> str: | |
| """Re-lay one pairwise result into the common frame.""" | |
| out: List[str] = [] | |
| pos = 0 | |
| run: List[str] = [] | |
| for ca, cb in zip(aligned_a, aligned_b): | |
| if ca == GAP: | |
| run.append(cb) | |
| continue | |
| need = inserts.get(pos, 0) | |
| out.append("".join(run).ljust(need, GAP)[:need] if need else "") | |
| run = [] | |
| out.append(cb) | |
| pos += 1 | |
| need = inserts.get(pos, 0) | |
| out.append("".join(run).ljust(need, GAP)[:need] if need else "") | |
| return "".join(out) | |
| rows: List[Dict[str, Any]] = [] | |
| frame_anchor = [] | |
| for pos, ch in enumerate(anchor): | |
| frame_anchor.append(GAP * inserts.get(pos, 0) + ch) | |
| frame_anchor.append(GAP * inserts.get(len(anchor), 0)) | |
| anchor_row = "".join(frame_anchor) | |
| rows.append({"name": label(anchor_i), "aligned": anchor_row, | |
| "identity_to_anchor": 100.0, "is_anchor": True}) | |
| for p in pairs: | |
| rows.append({"name": label(p["i"]), "aligned": expand(p["a"], p["b"]), | |
| "identity_to_anchor": p["identity"], "is_anchor": False}) | |
| width = max(len(r["aligned"]) for r in rows) | |
| for r in rows: | |
| r["aligned"] = r["aligned"].ljust(width, GAP) | |
| ids = [r["identity_to_anchor"] for r in rows if not r["is_anchor"]] | |
| return { | |
| "ok": True, | |
| "rows": rows, | |
| "columns": width, | |
| "sequences": len(rows), | |
| "anchor": label(anchor_i), | |
| "mean_identity_to_anchor": round(sum(ids) / len(ids), 1) if ids else 100.0, | |
| "method": ("Progressive alignment onto the longest sequence, using the " | |
| "engine's Needleman-Wunsch. Approximate: a simultaneous " | |
| "optimum is exponential and every practical tool " | |
| "approximates. Treat column boundaries in gappy regions as " | |
| "indicative."), | |
| } | |
| def score(sequences: Sequence[str], names: Optional[Sequence[str]] = None, | |
| *, positions: Optional[Sequence[int]] = None) -> Dict[str, Any]: | |
| """Per-column conservation across an alignment of homologs. | |
| Positions, when given, are numbered along the ANCHOR (the longest input), | |
| because that is the sequence a user is designing against. | |
| """ | |
| seqs = [_clean(s) for s in (sequences or [])] | |
| if len([s for s in seqs if s]) < MIN_SEQS: | |
| return {"ok": False, "kind": "too_few", | |
| "error": f"Conservation needs at least {MIN_SEQS} sequences; " | |
| f"got {len([s for s in seqs if s])}.", | |
| "next": "Add orthologs — BLAST the sequence and take the hits."} | |
| msa = align_many(seqs, names) | |
| if not msa.get("ok"): | |
| return msa | |
| rows = msa["rows"] | |
| anchor_row = next(r for r in rows if r["is_anchor"])["aligned"] | |
| n = len(rows) | |
| cols: List[Dict[str, Any]] = [] | |
| anchor_pos = 0 | |
| for c in range(msa["columns"]): | |
| column = [r["aligned"][c] for r in rows] | |
| anchor_ch = anchor_row[c] | |
| if anchor_ch != GAP: | |
| anchor_pos += 1 | |
| residues = [ch for ch in column if ch != GAP] | |
| if not residues: | |
| continue | |
| counts = Counter(residues) | |
| top, top_n = counts.most_common(1)[0] | |
| # Shannon entropy over observed residues. 0 = every sequence agrees. | |
| total = len(residues) | |
| H = -sum((k / total) * math.log2(k / total) for k in counts.values()) | |
| cols.append({ | |
| "column": c, | |
| "anchor_position": anchor_pos if anchor_ch != GAP else None, | |
| "anchor_residue": None if anchor_ch == GAP else anchor_ch, | |
| "consensus": top, | |
| "agreement_pct": round(100.0 * top_n / total, 1), | |
| # abs(): -sum(...) of an all-agreeing column yields IEEE -0.0, which | |
| # prints as "-0.0 bits" and reads like a bug. Entropy is never | |
| # negative. | |
| "entropy_bits": abs(round(H, 3)), | |
| "gaps": n - total, | |
| # A plain word, because "0.0 bits" is not what a bench scientist | |
| # reads. Thresholds are stated rather than hidden. | |
| "call": ("invariant" if H == 0.0 else | |
| "highly conserved" if H < 0.5 else | |
| "conserved" if H < 1.0 else | |
| "variable"), | |
| }) | |
| wanted = None | |
| if positions: | |
| want = {int(p) for p in positions} | |
| wanted = [c for c in cols if c["anchor_position"] in want] | |
| invariant = [c for c in cols if c["call"] == "invariant"] | |
| mean_id = msa["mean_identity_to_anchor"] | |
| redundant = mean_id > REDUNDANT_ABOVE | |
| return { | |
| "ok": True, | |
| "sequences": n, | |
| "anchor": msa["anchor"], | |
| "columns": len(cols), | |
| "invariant_count": len(invariant), | |
| "conservation": wanted if wanted is not None else cols, | |
| "mean_identity": mean_id, | |
| "alignment_method": msa["method"], | |
| # The limit that is about the INPUT, not the algorithm. Forty | |
| # sequences at 99% identity are one sequence counted forty times, and | |
| # every column will look invariant. | |
| "diversity_warning": ( | |
| f"The homologs are {mean_id}% identical to each other on average. " | |
| f"At that redundancy nearly every column looks conserved because " | |
| f"the set carries little independent evidence. Use more divergent " | |
| f"orthologs." if redundant else None), | |
| "trustworthy": (not redundant) and n >= MIN_SEQS, | |
| "caveat": ("Conservation is an OBSERVATION about the sequences given, " | |
| "not a property of the protein. It is only as meaningful as " | |
| "the homolog set: too few or too similar and it says " | |
| "nothing. It complements ESM-2 rather than confirming it — " | |
| "agreement between the two is genuine corroboration, " | |
| "disagreement is worth investigating, not averaging."), | |
| } | |