File size: 9,990 Bytes
1bf37a2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
"""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."),
    }