File size: 12,927 Bytes
a6af7a7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
"""Constrained Viterbi decoding of 15-class annotation logits into GFF3 genes.

A simplified port of the NTv3 genome-annotation decoder: the same 15-state
Tiberius-style HMM and start / splice / stop motif constraints, without the U12
(AT..AC) intron lane.
"""

import gzip
import itertools
from collections.abc import Callable
from pathlib import Path

import numba
import numpy as np

CLASSES: tuple[str, ...] = (
    "intergenic",
    "intron_p0",
    "intron_p1",
    "intron_p2",
    "CDS_p0",
    "CDS_p1",
    "CDS_p2",
    "splice_acceptor_p0",
    "splice_acceptor_p1",
    "splice_acceptor_p2",
    "splice_donor_p0",
    "splice_donor_p1",
    "splice_donor_p2",
    "start_codon",
    "stop_codon",
)
NUM_CLASSES = len(CLASSES)
CLASS_INDEX: dict[str, int] = {name: i for i, name in enumerate(CLASSES)}

# Tiberius mean lengths: intergenic ~10 kb, intron ~4.5 kb, exon ~200 bp.
_P_IR = 1 / 10_000
_P_I = 1 / 4_500
_P_E = 1 / 200

# Allowed transitions per state; the self-loop takes the remaining mass. Start,
# stop and splice states last exactly 1 bp.
TRANSITIONS: dict[str, dict[str, float]] = {
    "intergenic": {"start_codon": _P_IR},
    "intron_p0": {"splice_acceptor_p1": _P_I},
    "intron_p1": {"splice_acceptor_p2": _P_I},
    "intron_p2": {"splice_acceptor_p0": _P_I},
    "CDS_p0": {"CDS_p1": 1 - _P_E, "splice_donor_p1": _P_E},
    "CDS_p1": {"CDS_p2": 1 - 2 * _P_E, "splice_donor_p2": _P_E, "stop_codon": _P_E},
    "CDS_p2": {"CDS_p0": 1 - _P_E, "splice_donor_p0": _P_E},
    "splice_acceptor_p0": {"CDS_p1": 1.0},
    "splice_acceptor_p1": {"CDS_p2": 1.0},
    "splice_acceptor_p2": {"CDS_p0": 1.0},
    "splice_donor_p0": {"intron_p0": 1.0},
    "splice_donor_p1": {"intron_p1": 1.0},
    "splice_donor_p2": {"intron_p2": 1.0},
    "start_codon": {"CDS_p1": 1.0},
    "stop_codon": {"intergenic": 1.0},
}


def build_log_transitions() -> np.ndarray:
    """Returns the (15, 15) log transition matrix of the HMM."""
    matrix = np.zeros((NUM_CLASSES, NUM_CLASSES), dtype=np.float64)
    for src, dests in TRANSITIONS.items():
        i = CLASS_INDEX[src]
        matrix[i, i] = max(0.0, 1.0 - sum(dests.values()))
        for dst, prob in dests.items():
            matrix[i, CLASS_INDEX[dst]] = prob
    with np.errstate(divide="ignore"):
        return np.log(matrix.astype(np.float32))


LOG_TRANSITIONS = build_log_transitions()

# Base codes: A=0, C=1, G=2, T=3, anything else (N, IUPAC) = 4.
A, C, G, T, N = 0, 1, 2, 3, 4
_BASE_CODE_LUT = np.full(256, N, dtype=np.uint8)
for _base, _code in zip("ACGT", (A, C, G, T), strict=True):
    _BASE_CODE_LUT[ord(_base)] = _code
    _BASE_CODE_LUT[ord(_base.lower())] = _code


def encode_bases(sequence: str) -> np.ndarray:
    """Maps a DNA string to case-insensitive base codes."""
    return _BASE_CODE_LUT[np.frombuffer(sequence.encode("ascii"), dtype=np.uint8)]


def reverse_complement_codes(codes: np.ndarray) -> np.ndarray:
    """Reverse-complements an array of base codes."""
    return np.where(codes == N, N, T - codes)[::-1].astype(np.uint8)


# (offsets relative to the state position, bases, log-score) per accepted motif.
# Scores follow Tiberius: a slight TAG preference, and GT >> GC donors.
_MOTIFS: dict[str, list[tuple[tuple[int, ...], tuple[int, ...], float]]] = {
    "start": [((0, 1, 2), (A, T, G), 0.0)],
    "stop": [
        ((-2, -1, 0), (T, A, G), float(np.log(np.float32(0.34)))),
        ((-2, -1, 0), (T, A, A), float(np.log(np.float32(0.33)))),
        ((-2, -1, 0), (T, G, A), float(np.log(np.float32(0.33)))),
    ],
    "donor": [
        ((1, 2), (G, T), float(np.log(np.float32(0.99)))),
        ((1, 2), (G, C), float(np.log(np.float32(0.01)))),
    ],
    "acceptor": [((-2, -1), (A, G), 0.0)],
}
_MOTIF_STATES: dict[str, tuple[str, ...]] = {
    "start": ("start_codon",),
    "stop": ("stop_codon",),
    "donor": ("splice_donor_p0", "splice_donor_p1", "splice_donor_p2"),
    "acceptor": ("splice_acceptor_p0", "splice_acceptor_p1", "splice_acceptor_p2"),
}


def _motif_log_mask(
    codes: np.ndarray, rules: list[tuple[tuple[int, ...], tuple[int, ...], float]]
) -> np.ndarray:
    """Per-position log-score of one motif: -inf if no rule matches, 0 near an N."""
    length = len(codes)
    padded = np.pad(codes, (2, 2), constant_values=N)
    offsets = {off for rule_offsets, _, _ in rules for off in rule_offsets}

    def shifted(off: int) -> np.ndarray:
        return padded[2 + off : 2 + off + length]

    has_n = np.zeros(length, dtype=bool)
    for off in offsets:
        has_n |= shifted(off) == N
    score = np.full(length, -np.inf, dtype=np.float32)
    for rule_offsets, bases, log_score in rules:
        match = np.ones(length, dtype=bool)
        for off, base in zip(rule_offsets, bases, strict=True):
            match &= shifted(off) == base
        score[match] = log_score
    score[has_n] = 0.0
    # Motifs whose context falls outside the window are forbidden.
    pos = np.arange(length)
    score[(pos < -min(offsets)) | (pos >= length - max(offsets))] = -np.inf
    return score


def constraint_log_mask(codes: np.ndarray) -> np.ndarray:
    """(L, 15) additive log-mask vetoing start/stop/splice states off their motif."""
    mask = np.zeros((len(codes), NUM_CLASSES), dtype=np.float32)
    for motif, rules in _MOTIFS.items():
        column = _motif_log_mask(codes, rules)
        for state in _MOTIF_STATES[motif]:
            mask[:, CLASS_INDEX[state]] = column
    return mask


@numba.njit(cache=True)
def _viterbi(log_emissions: np.ndarray, log_transitions: np.ndarray) -> np.ndarray:
    length, num_states = log_emissions.shape
    backpointers = np.zeros((length, num_states), dtype=np.int8)
    scores = log_emissions[0].copy()
    stepped = np.empty(num_states, dtype=np.float32)
    for t in range(1, length):
        for j in range(num_states):
            best = -np.inf
            best_i = 0
            for i in range(num_states):
                value = scores[i] + log_transitions[i, j]
                if value > best:
                    best = value
                    best_i = i
            stepped[j] = best + log_emissions[t, j]
            backpointers[t, j] = best_i
        # Rescale by the running max so scores stay bounded; argmaxes are unchanged.
        offset = max(stepped.max(), np.float32(-1e30))
        for j in range(num_states):
            scores[j] = stepped[j] - offset
    path = np.empty(length, dtype=np.int8)
    path[-1] = np.argmax(scores)
    for t in range(length - 1, 0, -1):
        path[t - 1] = backpointers[t, path[t]]
    return path


def viterbi(
    log_emissions: np.ndarray, constrain_codes: np.ndarray | None
) -> np.ndarray:
    """Most likely state path of one strand, read 5'->3'.

    Args:
        log_emissions: (L, 15) log-probabilities in transcription direction.
        constrain_codes: Base codes in the same direction, or None to decode
            without motif constraints.

    Returns:
        (L,) int8 class indices.
    """
    log_emissions = np.asarray(log_emissions, dtype=np.float32)
    if constrain_codes is not None:
        log_emissions = log_emissions + constraint_log_mask(constrain_codes)
    return _viterbi(np.ascontiguousarray(log_emissions), LOG_TRANSITIONS)


def tile_windows(length: int, window: int) -> list[tuple[int, int]]:
    """(window start, first bp the window owns) covering [0, length) end to end.

    The last window is pulled back to end on the final base; the bp it shares
    with the previous window stay with the previous one.
    """
    if length < window:
        raise ValueError(f"Sequence of {length} bp is shorter than one window.")
    starts = list(range(0, length - window + 1, window))
    tiles = [(s, 0) for s in starts]
    if starts[-1] + window < length:
        last = length - window
        tiles.append((last, starts[-1] + window - last))
    return tiles


def annotate_sequence(
    sequence: str,
    predict: Callable[[str], tuple[np.ndarray, np.ndarray]],
    window: int,
    constrained: bool = True,
) -> tuple[np.ndarray, np.ndarray]:
    """Decodes both strands of a chromosome window by window.

    Args:
        sequence: Chromosome sequence (soft-masking allowed).
        predict: Maps a window's sequence to (L, 15) log-probabilities for the
            window itself and for its reverse complement.
        window: Window length in bp.
        constrained: Whether to apply the motif constraints.

    Returns:
        Plus- and minus-strand state paths in genomic order, each (len(sequence),).
    """
    codes = encode_bases(sequence)
    plus = np.zeros(len(sequence), dtype=np.int8)
    minus = np.zeros(len(sequence), dtype=np.int8)
    for start, keep in tile_windows(len(sequence), window):
        end = start + window
        log_probs_plus, log_probs_rc = predict(sequence[start:end])
        window_codes = codes[start:end]
        rc_codes = reverse_complement_codes(window_codes)
        states_plus = viterbi(log_probs_plus, window_codes if constrained else None)
        states_rc = viterbi(log_probs_rc, rc_codes if constrained else None)
        plus[start + keep : end] = states_plus[keep:]
        minus[start + keep : end] = states_rc[::-1][keep:]
    return plus, minus


_INTRON_STATES = {CLASS_INDEX[f"intron_p{p}"] for p in range(3)}
# Codon position at the 5' end of a CDS run, which sets its GFF3 phase.
_CODON_POSITION: dict[int, int] = {
    CLASS_INDEX[f"{kind}_p{p}"]: p
    for kind in ("CDS", "splice_acceptor", "splice_donor")
    for p in range(3)
} | {CLASS_INDEX["start_codon"]: 0, CLASS_INDEX["stop_codon"]: 0}
_STOP_CODONS = {(T, A, A), (T, A, G), (T, G, A)}


def _has_internal_stop(
    gene_states: np.ndarray, gene_codes: np.ndarray, strand: str
) -> bool:
    """Whether the spliced CDS holds an in-frame stop before its last codon."""
    is_intron = np.isin(gene_states, list(_INTRON_STATES))
    cds = gene_codes[~is_intron]
    if strand == "-":
        cds = reverse_complement_codes(cds)
    codons = cds[: len(cds) // 3 * 3].reshape(-1, 3)[:-1]
    return any(tuple(int(b) for b in codon) in _STOP_CODONS for codon in codons)


def find_genes(
    states: np.ndarray,
    strand: str,
    chrom: str,
    codes: np.ndarray | None = None,
    first_gene_id: int = 0,
) -> list[str]:
    """Turns one strand's state path into GFF3 gene/CDS/intron lines.

    A gene is a maximal run of non-intergenic states. Genes with an internal
    in-frame stop codon are dropped when ``codes`` is given.

    Args:
        states: (L,) class indices of one strand, in genomic order.
        strand: "+" or "-".
        chrom: Sequence name for the GFF3 seqid column.
        codes: Optional base codes of the chromosome, in genomic order.
        first_gene_id: Genes are numbered from first_gene_id + 1.

    Returns:
        GFF3 lines, each gene followed by its sub-features.
    """
    is_genic = np.concatenate([[False], states != CLASS_INDEX["intergenic"], [False]])
    edges = np.flatnonzero(is_genic[1:] != is_genic[:-1])
    feature_type = np.where(
        np.isin(states, list(_INTRON_STATES)), 2, np.where(states == 0, 0, 1)
    )
    type_changes = np.flatnonzero(feature_type[1:] != feature_type[:-1]) + 1
    lines: list[str] = []
    gene_id = first_gene_id
    for gene_start, gene_end in zip(edges[::2], edges[1::2], strict=True):
        gene_states = states[gene_start:gene_end]
        if codes is not None and _has_internal_stop(
            gene_states, codes[gene_start:gene_end], strand
        ):
            continue
        gene_id += 1
        gid = f"gene_{gene_id}_{'p' if strand == '+' else 'm'}"
        lines.append(
            f"{chrom}\tNTv3\tgene\t{gene_start + 1}\t{gene_end}\t.\t{strand}\t.\tID={gid}"
        )
        lo = np.searchsorted(type_changes, gene_start, "right")
        hi = np.searchsorted(type_changes, gene_end, "left")
        bounds = [gene_start, *type_changes[lo:hi].tolist(), gene_end]
        for feat_start, feat_end in itertools.pairwise(bounds):
            is_cds = feature_type[feat_start] == 1
            phase = "."
            if is_cds:
                five_prime = feat_start if strand == "+" else feat_end - 1
                phase = str((3 - _CODON_POSITION[int(states[five_prime])]) % 3)
            kind = "CDS" if is_cds else "intron"
            lines.append(
                f"{chrom}\tNTv3\t{kind}\t{feat_start + 1}\t{feat_end}\t.\t{strand}"
                f"\t{phase}\tID={gid}_{kind}_{feat_start + 1};Parent={gid}"
            )
    return lines


def write_gff3(path: str | Path, lines: list[str]) -> None:
    """Writes GFF3 lines sorted by (seqid, start), gzipped if path ends in .gz."""
    ordered = sorted(
        lines, key=lambda line: (line.split("\t")[0], int(line.split("\t")[3]))
    )
    text = "##gff-version 3\n" + "\n".join(ordered) + "\n"
    opener = gzip.open if str(path).endswith(".gz") else open
    with opener(path, "wt") as handle:
        handle.write(text)