Source code for mllm_shap.alignment.ctc

"""CTC forced alignment (Viterbi over the blank-extended target sequence).

@domain alignment
@see [[knowledge/architecture/alignment]]
@tested-by [[tests/alignment/test_ctc.py]]
"""

from collections.abc import Sequence
from dataclasses import dataclass

import numpy as np
from numpy.typing import NDArray

BLANK_STATE: int = -1
"""Path value of a frame aligned to the blank label."""


[docs] @dataclass(frozen=True, slots=True) class TargetSpan: """Frames ``[start, end)`` that one target label occupies.""" start: int """First frame of the label.""" end: int """Frame after the last one.""" confidence: float """Mean probability of the label over its frames."""
[docs] def forced_align( log_probs: NDArray[np.floating], targets: Sequence[int], blank: int = 0 ) -> NDArray[np.int64]: """Return, per frame, the index into ``targets`` it aligns to, or ``BLANK_STATE``. The most probable monotonic path over ``targets`` with optional blanks between labels and a mandatory blank between two equal neighbours (standard CTC). Raises: ValueError: If ``log_probs`` has too few frames for ``targets``. """ frames, n = len(log_probs), len(targets) ext = np.full(2 * n + 1, blank) ext[1::2] = targets states = len(ext) # A label state may skip the blank before it unless it repeats the previous label. skip = np.zeros(states, dtype=bool) skip[3::2] = ext[3::2] != ext[1:-2:2] score = np.full(states, -np.inf) score[:2] = log_probs[0, ext[:2]] back = np.zeros((frames, states), dtype=np.int64) for t in range(1, frames): stay, step = score, np.concatenate(([-np.inf], score[:-1])) jump = np.where(skip, np.concatenate(([-np.inf] * 2, score[:-2])), -np.inf) moves = np.stack([stay, step, jump]) best = np.argmax(moves, axis=0) back[t] = np.arange(states) - best score = moves[best, np.arange(states)] + log_probs[t, ext] end = states - 1 if score[-1] >= score[-2] else states - 2 if not np.isfinite(score[end]): msg = f"{frames} frames cannot hold {n} targets" raise ValueError(msg) path = np.empty(frames, dtype=np.int64) for t in range(frames - 1, -1, -1): path[t] = (end - 1) // 2 if end % 2 else BLANK_STATE end = back[t, end] return path
[docs] def target_spans( path: NDArray[np.int64], log_probs: NDArray[np.floating], targets: Sequence[int] ) -> list[TargetSpan]: """Return the frame span and mean probability of every target in ``path``.""" spans: list[TargetSpan] = [] for index, label in enumerate(targets): frames = np.flatnonzero(path == index) probability = np.exp(log_probs[frames, label]).mean() spans.append( TargetSpan(int(frames[0]), int(frames[-1]) + 1, float(probability)) ) return spans