syntheogenesis / dee /core /conservation.py
github-actions[bot]
Deploy 90a38dc
1bf37a2
Raw
History Blame Contribute Delete
9.99 kB
"""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."),
}