Algoritmo de Viterbi

Visão geral

O algoritmo de Viterbi é um método clássico de programação dinâmica (dynamic programming) para decodificar sequências em Modelos Ocultos de Markov (Hidden Markov Models, HMMs) e outros modelos probabilísticos de estado finito (finite-state). Dada uma sequência observada (por exemplo, características acústicas, palavras, bases de DNA), ele encontra a única sequência mais provável de estados ocultos — também chamada de caminho MAP (MAP path) (sequência de estados maximum a posteriori):

[ \hat{z}{1:T}=\arg\max{z_{1:T}} p(z_{1:T}\mid x_{1:T}) ]

Como (p(z_{1:T}\mid x_{1:T}) \propto p(z_{1:T}, x_{1:T})), o Viterbi é tipicamente apresentado como a maximização da probabilidade conjunta (p(z_{1:T}, x_{1:T})). A ideia-chave é que, embora o número de possíveis sequências de estados cresça exponencialmente com o comprimento da sequência, a estrutura de Markov permite que um caminho ótimo seja computado em tempo polinomial reutilizando sub-resultados.

O Viterbi é uma ferramenta central em Modelos Ocultos de Markov, amplamente usada em reconhecimento de fala, rotulagem em PLN, biologia computacional e decodificação em modelos gráficos (é essencialmente uma forma máx-produto (max-product) de propagação de crenças em uma cadeia).

Configuração do problema: notação de HMM

Um HMM tipicamente especifica:

  • Um conjunto de estados ocultos (z_t \in {1,\dots,N})
  • Observações (x_t) (símbolos discretos ou vetores de características contínuos)
  • Distribuição inicial de estados: (\pi_i = p(z_1=i))
  • Probabilidades de transição: (a_{ij} = p(z_t=j \mid z_{t-1}=i))
  • Verossimilhanças de emissão: (b_j(x_t) = p(x_t \mid z_t=j))

A probabilidade conjunta se fatoriza como:

[ p(z_{1:T}, x_{1:T}) = \pi_{z_1} b_{z_1}(x_1) \prod_{t=2}^{T} a_{z_{t-1}, z_t} , b_{z_t}(x_t) ]

O objetivo de decodificação (Viterbi) é:

[ \hat{z}{1:T} = \arg\max{z_{1:T}} p(z_{1:T}, x_{1:T}) ]

Isso difere de computar marginais (p(z_t \mid x_{1:T})), o que é tratado pelo Algoritmo Forward-Backward.

A ideia de programação dinâmica (o “treliça”)

Considere o melhor caminho que termina no estado (i) no tempo (t). Se você conhecesse o melhor caminho para cada estado anterior (j) no tempo (t-1), então a melhor forma de chegar a (i) no tempo (t) deve vir do melhor predecessor (j) mais uma transição (j\to i).

Isso é a marca registrada da programação dinâmica: subestrutura ótima.

Definimos uma tabela de PD:

  • (\delta_t(i)): pontuação (probabilidade) do caminho mais provável que termina no estado (i) no tempo (t)
  • (\psi_t(i)): ponteiro de retorno (backpointer), o índice do estado predecessor (j) que atingiu o máximo

O cálculo ao longo do tempo forma uma grade frequentemente chamada de treliça (trellis) (tempo em um eixo, estados no outro).

Recorrência de Viterbi (equações centrais)

Inicialização (\(t=1\))

[ \delta_1(i) = \pi_i , b_i(x_1), \quad \psi_1(i) = 0 ]

Recursão (\(t=2,\dots,T\))

[ \delta_t(i) = b_i(x_t), \max_{j \in {1,\dots,N}} \left[\delta_{t-1}(j), a_{j i}\right] ]

[ \psi_t(i) = \arg\max_{j \in {1,\dots,N}} \left[\delta_{t-1}(j), a_{j i}\right] ]

Terminação

[ \hat{z}_T = \arg\max_i \delta_T(i) ]

(Se o modelo incluir probabilidades explícitas de estado final, incorpore-as aqui de forma semelhante.)

Retrotraçado (recuperando o caminho MAP)

Para (t=T-1,\dots,1):

[ \hat{z}t = \psi{t+1}(\hat{z}_{t+1}) ]

Os ponteiros de retorno são o que convertem “melhor pontuação por estado final” na sequência de estados globalmente ótima.

Mini-exemplo trabalhado (HMM discreto)

Suponha dois estados ocultos:

  • (1=) Rainy, (2=) Sunny
    Observações: (x_t \in {\text{walk}, \text{shop}, \text{clean}})

Parâmetros:

  • (\pi = [0.6, 0.4])

  • (A = \begin{bmatrix} 0.7 & 0.3 \ 0.4 & 0.6 \end{bmatrix})

  • Emissões (b_i(x)):

    • Rainy: walk 0.1, shop 0.4, clean 0.5
    • Sunny: walk 0.6, shop 0.3, clean 0.1

Sequência de observações: (x_{1:3} = [\text{walk}, \text{shop}, \text{clean}])

\(t=1\)

  • (\delta_1(\text{Rainy}) = 0.6 \cdot 0.1 = 0.06)
  • (\delta_1(\text{Sunny}) = 0.4 \cdot 0.6 = 0.24)

\(t=2\), observação “shop”

Para Rainy (estado 1):

  • de Rainy: (0.06 \cdot 0.7 = 0.042)
  • de Sunny: (0.24 \cdot 0.4 = 0.096) (máx)

Então:

  • (\delta_2(1) = 0.4 \cdot 0.096 = 0.0384)
  • (\psi_2(1) = \text{Sunny})

Para Sunny (estado 2):

  • de Rainy: (0.06 \cdot 0.3 = 0.018)
  • de Sunny: (0.24 \cdot 0.6 = 0.144) (máx)

Então:

  • (\delta_2(2) = 0.3 \cdot 0.144 = 0.0432)
  • (\psi_2(2) = \text{Sunny})

\(t=3\), observação “clean”

Para Rainy:

  • de Rainy: (0.0384 \cdot 0.7 = 0.02688)
  • de Sunny: (0.0432 \cdot 0.4 = 0.01728) (máx é de Rainy)

(\delta_3(1) = 0.5 \cdot 0.02688 = 0.01344), (\psi_3(1)=\text{Rainy})

Para Sunny:

  • de Rainy: (0.0384 \cdot 0.3 = 0.01152)
  • de Sunny: (0.0432 \cdot 0.6 = 0.02592) (máx de Sunny)

(\delta_3(2)=0.1 \cdot 0.02592 = 0.002592), (\psi_3(2)=\text{Sunny})

Terminação e retrotraçado

  • Melhor estado final: Rainy ((0.01344) vs (0.002592))
  • (\hat{z}_3 = \text{Rainy})
  • (\hat{z}_2 = \psi_3(\text{Rainy}) = \text{Rainy})
  • (\hat{z}_1 = \psi_2(\text{Rainy}) = \text{Sunny})

Sequência de estados decodificada: Sunny → Rainy → Rainy

Isso ilustra o comportamento essencial: o Viterbi faz escolhas de predecessor localmente ótimas que são globalmente ótimas porque o estado da PD resume todo o histórico necessário.

Pseudocódigo (domínio de probabilidade)

Inputs: pi[1..N], a[1..N,1..N], emission b_i(x), observations x[1..T]

delta[1..T,1..N]
psi[1..T,1..N]

# init
for i in 1..N:
    delta[1,i] = pi[i] * b_i(x[1])
    psi[1,i] = 0

# recurse
for t in 2..T:
    for i in 1..N:
        best_val = -inf
        best_arg = 1
        for j in 1..N:
            val = delta[t-1,j] * a[j,i]
            if val > best_val:
                best_val = val
                best_arg = j
        delta[t,i] = b_i(x[t]) * best_val
        psi[t,i] = best_arg

# terminate
z[T] = argmax_i delta[T,i]

# backtrace
for t in T-1 down to 1:
    z[t] = psi[t+1, z[t+1]]

return z[1..T]

Na prática, você quase sempre executa o Viterbi em espaço logarítmico (log-space) para evitar subfluxo numérico.

Viterbi em espaço logarítmico (recomendado)

Probabilidades ao longo de um caminho multiplicam ao longo do tempo, rapidamente sofrendo subfluxo para 0 em ponto flutuante quando (T) é grande. A correção padrão é tomar logaritmos e transformar produtos em somas:

Defina (\Delta_t(i) = \log \delta_t(i)). Então:

Inicialização

[ \Delta_1(i) = \log \pi_i + \log b_i(x_1) ]

Recursão

[ \Delta_t(i) = \log b_i(x_t) + \max_j \left[\Delta_{t-1}(j) + \log a_{j i}\right] ]

Os ponteiros de retorno (\psi_t(i)) não mudam (eles armazenam o argmax).

Observações práticas para espaço logarítmico

  • Use (-\infty) para transições/emissões impossíveis ((\log 0)).
  • Pré-calcule (\log A), (\log \pi) e (se discreto) (\log B) para velocidade.
  • Para emissões contínuas (por exemplo, Gaussianas), compute log-verossimilhanças (log-likelihoods) diretamente.

Complexidade de tempo e espaço

Seja:

  • (T) = comprimento da sequência
  • (N) = número de estados

Complexidade de tempo

A recorrência direta computa, para cada tempo (t) e estado (i), um máximo sobre todos os estados predecessores (j):

  • Tempo: (\mathcal{O}(T N^2))

Isso frequentemente é aceitável para (N) pequeno a médio, mas pode ser caro para espaços de estados grandes.

Acelerações comuns (dependentes do modelo):

  • Transições esparsas: se cada estado tem apenas (k) transições de saída, a complexidade se torna (\mathcal{O}(T N k)).
  • Transições estruturadas: certas estruturas em banda ou convolucionais podem ser aceleradas.
  • Busca em feixe (beam search) (aproximada): mantém apenas os top-(K) estados por passo de tempo.

Complexidade de espaço

Se você armazenar a tabela inteira de PD e os ponteiros de retorno:

  • (\delta): (\mathcal{O}(T N))
  • (\psi): (\mathcal{O}(T N))

Total: memória (\mathcal{O}(T N)) (as constantes dependem dos tamanhos de dtype).

Se você só precisa da melhor pontuação (não do caminho), pode manter apenas a coluna anterior:

  • Pontuações: (\mathcal{O}(N))

Mas para reconstruir o caminho, tipicamente você armazena ponteiros de retorno. Opções para economizar memória incluem:

  • Checkpointing: armazenar ponteiros de retorno em intervalos e recomputar segmentos.
  • Variantes online / streaming: consolidar partes do caminho quando o melhor prefixo se torna estável (comum em decodificação de fala).

Variantes de decodificação e objetivos relacionados

“Decodificação” pode significar coisas diferentes dependendo do que você quer otimizar.

Decodificação Viterbi (caminho MAP)

  • Saída: uma única sequência (\hat{z}_{1:T})
  • Objetivo: maximizar a probabilidade conjunta (ou a posteriori do caminho inteiro)

Isso é o que “o algoritmo de Viterbi” geralmente significa.

Decodificação posterior (MAP por passo de tempo)

Às vezes você quer o estado mais provável em cada tempo:

[ \hat{z}t = \arg\max_i p(z_t=i \mid x{1:T}) ]

Isso usa marginais do Algoritmo Forward-Backward, não o Viterbi. A decodificação posterior pode produzir uma sequência que não é um caminho válido de alta probabilidade sob restrições de transição, pois otimiza cada posição de forma independente.

Viterbi N-melhores (múltiplos melhores caminhos)

Em vez de um melhor caminho, você pode querer os top (K) caminhos. Isso é útil para reordenamento (reranking) ou restrições a jusante. Abordagens comuns:

  • Manter (K) melhores hipóteses por estado/tempo (contabilidade mais complexa).
  • Usar métodos de treliça / grafo em autômatos ponderados.

Busca em feixe / poda (Viterbi aproximado)

Em modelos grandes (por exemplo, reconhecimento de fala), a decodificação exata (\mathcal{O}(T N^2)) pode ser lenta demais. A busca em feixe:

  • Mantém apenas estados cuja pontuação está dentro de um limiar do melhor atual (ou os top (K) estados).
  • Troca exatidão por velocidade.

Viterbi em CRFs e outros modelos de estado finito

A recorrência de Viterbi se generaliza para qualquer modelo estruturado em cadeia onde a pontuação se decompõe em termos locais:

  • Campos Aleatórios Condicionais (Conditional Random Fields, CRFs) de cadeia linear (substitua probabilidades por log-potenciais)
  • Caminhos de pontuação máxima em grafos de fatores (máx-soma)

Nesses contextos, “Viterbi” frequentemente é descrito como programação dinâmica máx-soma (max-sum).

Variantes segmentais e semi-Markov

Se estados podem persistir por segmentos de comprimento variável (modelos semi-Markov), a recorrência tipicamente inclui um máximo sobre possíveis comprimentos de segmento, aumentando a complexidade a menos que seja otimizada.

Interpretando o Viterbi como caminho mais curto/mais longo em um grafo

A treliça pode ser vista como um grafo acíclico dirigido (DAG):

  • Nós: ((t,i))
  • Peso da aresta: (\log a_{ji} + \log b_i(x_t)) (mais inicialização)
  • Objetivo: encontrar o caminho de peso máximo do início até qualquer nó final

Essa perspectiva conecta o Viterbi a:

  • Caminho mais longo em um DAG (resolvível por PD)
  • Transdutores de estado finito ponderados (WFSTs) usados em fala e OCR

Notas práticas de implementação

Lidando com zeros e suavização

Se algumas transições ou emissões são zero, o espaço logarítmico usa (-\infty). Isso está correto se esses eventos são realmente impossíveis, mas frequentemente zeros vêm de dados limitados. Correções típicas:

  • Suavização aditiva para emissões/transições discretas
  • Backoff ou interpolação
  • Estimação de parâmetros com regularização (veja Expectation-Maximization para treinamento de HMM via Baum–Welch)

Emissões contínuas

Para HMMs Gaussianos, você calcula:

[ \log b_i(x_t) = \log \mathcal{N}(x_t; \mu_i, \Sigma_i) ]

e insere no Viterbi em espaço logarítmico. Certifique-se de usar rotinas numericamente estáveis de logpdf Gaussiana.

Desempate e determinismo

Se múltiplos estados predecessores produzem o mesmo máximo, (\arg\max) é ambíguo. Para saídas reprodutíveis:

  • Escolha o menor índice em empates, ou
  • Escolha com base em uma heurística secundária (por exemplo, preferir auto-transições)

Restrições e transições mascaradas

Muitas aplicações impõem restrições:

  • Transições de estado proibidas
  • Rótulos conhecidos em certos tempos (“forced alignment”)
  • Estados de início/fim

Elas são facilmente tratadas definindo as pontuações logarítmicas correspondentes de transição/emissão como (-\infty).

Vetorização e processamento em lote

Para (N) moderado, você pode acelerar com álgebra linear:

  • Para cada tempo (t), compute uma matriz de pontuações candidatas: (\Delta_{t-1}[:,None] + \log A)
  • Tome máximos por linha/coluna dependendo do layout

Isso é comum em implementações NumPy/PyTorch, embora um gerenciamento cuidadoso de memória importe quando (N) é grande.

Implementação de referência em Python (espaço logarítmico)

A seguir está uma implementação compacta para um HMM de emissões discretas. Ela assume que você consegue consultar (\log b_i(x_t)) para cada estado e tempo.

import numpy as np

def viterbi_log(pi_log, A_log, emit_log):
    """
    Log-space Viterbi.

    Args:
      pi_log:  (N,) log initial probabilities
      A_log:   (N, N) log transition probs, A_log[j, i] = log p(i|j)
      emit_log:(T, N) log emission likelihoods, emit_log[t, i] = log p(x_t|i)

    Returns:
      path: (T,) int state indices
      best_logp: log probability of best path
    """
    T, N = emit_log.shape
    delta = np.full((T, N), -np.inf, dtype=np.float64)
    psi = np.zeros((T, N), dtype=np.int32)

    # init
    delta[0] = pi_log + emit_log[0]
    psi[0] = -1

    # recursion
    for t in range(1, T):
        # scores[j, i] = delta[t-1, j] + A_log[j, i]
        scores = delta[t - 1][:, None] + A_log
        psi[t] = np.argmax(scores, axis=0)
        delta[t] = emit_log[t] + np.max(scores, axis=0)

    # termination
    last = int(np.argmax(delta[T - 1]))
    best_logp = float(delta[T - 1, last])

    # backtrace
    path = np.zeros(T, dtype=np.int32)
    path[T - 1] = last
    for t in range(T - 2, -1, -1):
        path[t] = psi[t + 1, path[t + 1]]

    return path, best_logp

Em sistemas reais você pode precisar de:

  • (A) esparsa (armazenar listas de adjacência)
  • poda por feixe
  • processar múltiplas sequências em lote com padding/máscaras

Aplicações

A decodificação de Viterbi aparece em qualquer lugar em que uma sequência é gerada por um processo de Markov latente:

  • Rotulagem em PLN: rotulagem morfossintática (part-of-speech tagging), reconhecimento de entidades nomeadas (HMMs historicamente; CRFs comumente)
  • Reconhecimento de fala: decodificação de sequências de estados ou fones; frequentemente integrado a decodificadores WFST com poda
  • Biologia computacional: identificação de genes, anotação de domínios de proteínas
  • Robótica / rastreamento: estimação de modo discreto em modelos de espaço de estados com comutação
  • Correção de erros / comunicações: códigos convolucionais (o Viterbi originalmente ficou famoso aqui)

Equívocos comuns e armadilhas

  • “O Viterbi retorna o estado mais provável em cada tempo.”
    Não necessariamente. Ele retorna a sequência inteira mais provável. O estado mais provável no tempo (t) é uma questão marginal resolvida por forward-backward.

  • Subfluxo no espaço de probabilidade.
    Para sequências longas, multiplicações de probabilidades brutas viram zero. Use espaço logarítmico.

  • Comparar probabilidades entre sequências de comprimentos diferentes.
    Se você decodifica sequências de comprimentos variados, log-probabilidades conjuntas brutas escalam com (T). Considere log-probabilidade média por passo se você precisar de pontuações comparáveis.

  • Caminhos excessivamente confiantes com parâmetros mal estimados.
    Se transições/emissões forem “pontiagudas” demais devido a dados limitados, o Viterbi pode travar em caminhos irreais. Suavização/regularização ajuda.

Relação com treinamento e inferência em HMMs

  • Treinamento (aprender (A, B, \pi)) é comumente feito com Expectation-Maximization (Baum–Welch), que usa marginais do forward-backward.
  • Decodificação (encontrar a melhor sequência de estados ocultos) é o que o Viterbi fornece após os parâmetros serem conhecidos.
  • Um conceito relacionado é o treinamento de Viterbi (Viterbi training) (EM duro), que alterna entre decodificação de Viterbi e atualização de parâmetros com base no único melhor caminho — mais rápido, porém menos robusto estatisticamente do que o EM completo.

Resumo

  • O algoritmo de Viterbi computa a sequência MAP de estados ocultos em um HMM via programação dinâmica.
  • Ele mantém:
    • (\delta_t(i)): melhor pontuação de caminho terminando no estado (i) no tempo (t)
    • (\psi_t(i)): ponteiros de retorno para reconstruir o caminho
  • A complexidade é tipicamente (\mathcal{O}(T N^2)) em tempo e (\mathcal{O}(T N)) em espaço, com muitas otimizações práticas (esparsidade, poda, checkpointing).
  • Cálculo em espaço logarítmico é a abordagem prática padrão para estabilidade numérica.
  • Variantes como decodificação posterior, decodificação N-melhores e busca em feixe atendem a objetivos diferentes ou necessidades de escalabilidade.

Para uma inferência complementar que computa marginais de estado em vez de um único melhor caminho, veja o Algoritmo Forward-Backward.