Computação Numérica

Visão geral: por que a computação numérica importa em IA

Sistemas modernos de IA são programas numéricos: eles manipulam quantidades de valor real (pesos, ativações, probabilidades, gradientes) usando aritmética de precisão finita. A maioria dos bugs mais surpreendentes em treinamento e inferência—NaNs, perdas explosivas, “funciona em float64 mas não em float32”, resultados não determinísticos—vem de um descompasso entre a matemática de números reais e a computação de ponto flutuante (floating-point).

A computação numérica para IA normalmente gira em torno de quatro temas:

  • Ponto flutuante (floating point): como números reais são representados no hardware (geralmente IEEE 754).
  • Precisão e arredondamento (precision and rounding): como pequenos erros surgem e se acumulam.
  • Estabilidade e condicionamento (stability and conditioning): quando um algoritmo amplifica erros vs. quando os controla.
  • Diferenciação automática (automatic differentiation, autodiff): computar gradientes de forma confiável e eficiente para otimização, especialmente via Retropropagação e Descida do Gradiente.

Este artigo foca em fundamentos que aparecem constantemente no treinamento de redes neurais, na modelagem probabilística e em ML científico.

Números de ponto flutuante (essenciais do IEEE 754)

Representação: sinal, expoente, significando

A maioria das cargas de trabalho de IA usa formatos IEEE 754 de ponto flutuante como:

  • float32 (“precisão simples”): 1 bit de sinal, 8 bits de expoente, 23 bits de fração (24 bits de precisão com o 1 implícito à esquerda).
  • float64 (“precisão dupla”): 1 bit de sinal, 11 bits de expoente, 52 bits de fração (53 bits de precisão).
  • float16: 1 sinal, 5 expoente, 10 fração (baixa precisão, faixa limitada).
  • bfloat16: 1 sinal, 8 expoente, 7 fração (mesma faixa de expoente do float32, menos precisão).

Um float representa números aproximadamente como:

[ x = (-1)^s \times (1.\text{fraction}) \times 2^{(\text{exponent}-\text{bias})} ]

Isso dá aos floats duas propriedades cruciais:

  • Precisão finita: apenas um conjunto finito de números reais é representável.
  • Espaçamento não uniforme: números representáveis são densos perto de 0 e ficam mais espaçados para magnitudes maiores.

Épsilon de máquina e intuição de ULP

Dois conceitos úteis:

  • Épsilon de máquina (\epsilon): aproximadamente a diferença entre 1 e o próximo número representável acima de 1.

    • float32: (\epsilon \approx 1.19\times 10^{-7})
    • float64: (\epsilon \approx 2.22\times 10^{-16})
  • ULP (“unit in the last place”): o espaçamento entre floats representáveis adjacentes em uma dada magnitude. Para números grandes, 1 ULP pode ser bem grande.

Na prática: se seus valores estão em torno de (10^8) em float32, você não consegue mais representar incrementos de 1—somar 1 pode não fazer nada.

Modos de arredondamento

O IEEE 754 define o comportamento de arredondamento; o padrão na maioria dos hardwares é arredondar para o mais próximo, empates para par (round-to-nearest, ties-to-even). Isso ajuda a reduzir viés sistemático, mas o erro de arredondamento ainda é inevitável.

Valores especiais: ±0, ±∞, NaN, subnormais

Ponto flutuante inclui valores especiais importantes em ML:

  • Infinito (inf): overflow (ex.: exp(1000) em float32).
  • NaN: operações inválidas (ex.: 0/0, sqrt(-1), inf - inf). NaNs se propagam e podem envenenar o treinamento silenciosamente.
  • Subnormais (a.k.a. denormals): representam magnitudes muito pequenas com menos precisão; podem ser mais lentos em alguns hardwares e podem ser “flushados” para zero em alguns modos de acelerador.

Precisão, erro e por que `a + b + c` não é “apenas matemática”

A aritmética de ponto flutuante não é associativa

Na aritmética real: ((a+b)+c = a+(b+c)). Em ponto flutuante, o arredondamento quebra a associatividade.

import numpy as np

a = np.float32(1e8)
b = np.float32(1.0)
c = np.float32(-1e8)

print((a + b) + c)  # often 0.0
print(a + (b + c))  # often 1.0

Explicação: em float32, 1e8 + 1 arredonda de volta para 1e8 porque o incremento é menor que 1 ULP nessa escala, então o +1 se perde.

Cancelamento catastrófico

Se dois números quase iguais são subtraídos, a maioria dos dígitos iniciais se cancela e o erro relativo explode.

Exemplo: considere computar (1 - \cos(x)) para (x) pequeno. O cálculo direto perde precisão porque (\cos(x)\approx 1).

Uma alternativa numericamente estável usa uma identidade trigonométrica:

[ 1-\cos(x) = 2\sin^2(x/2) ]

Esta é uma lição canônica de “mesma matemática, estabilidade diferente”.

Erro de acumulação em somas

Somar muitos números (termos de perda, gradientes, escores de atenção) acumula erro de arredondamento.

Melhorias comuns:

  • Soma par a par (redução em árvore) costuma ser melhor do que esquerda-para-direita.
  • Soma de Kahan compensa bits de baixa ordem perdidos.

Muitos kernels de ML já usam estratégias de redução com consciência numérica, mas ainda surgem problemas quando os valores variam muito em magnitude.

Condicionamento vs. estabilidade: problema vs. algoritmo

Dois conceitos relacionados, mas distintos:

Condicionamento (o problema)

Uma função (f(x)) é mal condicionada se pequenas perturbações na entrada causam grandes mudanças na saída. Condicionamento é sobre a matemática do problema.

Exemplo: computar (f(x)=\log(x)) perto de (x=0) é inerentemente sensível: pequenas mudanças relativas em (x) levam a grandes mudanças absolutas em (\log(x)).

Estabilidade (o algoritmo)

Um algoritmo é numericamente estável se ele não amplifica excessivamente erros de arredondamento/representação. Estabilidade é sobre o método usado para calcular algo.

Um exemplo clássico relacionado a ML é a softmax (próxima seção): a função é adequada, mas um algoritmo ingênuo pode dar overflow.

Na prática, você quer:

  • formulações bem condicionadas quando possível, e
  • algoritmos estáveis mesmo quando o condicionamento é imperfeito.

Padrões de estabilidade numérica em ML

Softmax estável e truque de log-sum-exp

Softmax ingênua:

[ \text{softmax}(z_i)=\frac{e^{z_i}}{\sum_j e^{z_j}} ]

Isso pode dar overflow quando (z) contém valores grandes.

Softmax estável subtrai o máximo:

[ \text{softmax}(z_i)=\frac{e^{z_i - m}}{\sum_j e^{z_j - m}},\quad m=\max_j z_j ]

De forma semelhante, para log-verossimilhanças você frequentemente precisa de:

[ \log\sum_j e^{z_j} ]

Use log-sum-exp:

[ \text{LSE}(z)= m + \log\sum_j e^{z_j-m} ]

Exemplo prático:

import numpy as np

def softmax_stable(z):
    z = np.asarray(z, dtype=np.float64)
    m = np.max(z)
    e = np.exp(z - m)
    return e / np.sum(e)

z = np.array([1000, 1001, 1002], dtype=np.float64)
print(softmax_stable(z))

A maioria das bibliotecas de deep learning implementa softmax estável internamente, mas a instabilidade ainda aparece quando você reimplementa perdas personalizadas ou variantes de atenção.

Sigmoide estável e entropia cruzada binária

A sigmoide (\sigma(x)=1/(1+e^{-x})) pode dar overflow no exp para valores negativos/positivos grandes se implementada de forma ingênua. Bibliotecas usam formulações estáveis.

Entropia cruzada binária também se beneficia de formas combinadas estáveis (ex.: “BCE with logits”) que evitam computar sigmoid e log separadamente.

Regra prática: prefira funções de perda “with logits” quando disponíveis.

`log1p` e `expm1`

Quando (x) é pequeno:

  • log(1 + x) perde precisão se 1 + x arredonda para 1.
  • exp(x) - 1 perde precisão devido ao cancelamento.

Use funções especializadas:

  • log1p(x) computa (\log(1+x)) com precisão.
  • expm1(x) computa (e^x - 1) com precisão.

Elas aparecem em modelos probabilísticos e em certos cálculos de ativação/perda.

Normalização e escalonamento

Operações como batch norm, layer norm, RMS norm e normalização de atenção dependem de:

  • computação estável de média/variância,
  • evitar divisão por valores muito pequenos (adicione (\epsilon)),
  • cuidado com o dtype em reduções.

Em precisão mista, é comum:

  • manter acumuladores (somas de quadrados, média/variância) em float32 mesmo se as entradas são float16/bfloat16.

Underflow/overflow e dinâmica de gradientes

Redes neurais podem criar valores intermediários extremamente grandes ou pequenos:

  • exp em atenção ou verossimilhanças: overflow.
  • multiplicar muitas probabilidades: underflow para 0.
  • cadeias longas de Jacobianos: gradientes que desaparecem/explodem (parte numérico, parte modelagem).

Mitigações incluem:

  • trabalhar em espaço log (probabilidades em log),
  • normalização (softmax, log-sum-exp),
  • escolhas de inicialização e arquitetura (conexões residuais, camadas de normalização),
  • clipping de gradiente.

Precisão na prática: float32, float16, bfloat16 e precisão mista

Faixa vs. precisão

  • float16: faixa de expoente limitada → overflow/underflow é comum; a precisão também é limitada.
  • bfloat16: faixa de expoente como float32 → muito menos overflow/underflow, mas apenas ~7 bits de mantissa → precisão grosseira.
  • float32: padrão histórico para estabilidade de treinamento.
  • float64: raramente usado para treinamento em grande escala (lento, pesado em memória), mas útil para depuração e certos modelos científicos.

Treinamento em precisão mista

Precisão mista normalmente significa:

  • armazenar pesos/ativações em float16/bfloat16 para velocidade e memória,
  • acumular certas reduções e estados do otimizador em float32,
  • às vezes manter uma “cópia mestre” dos pesos em float32.

Uma técnica-chave para float16 é escalonamento de perda (loss scaling): multiplicar a perda por um fator de escala antes do backprop para reduzir underflow de gradientes, e então desfazer a escala dos gradientes antes do passo do otimizador.

A maioria dos frameworks modernos (PyTorch AMP, JAX, TensorFlow mixed precision) automatiza isso.

Orientação prática

  • Use bfloat16 quando disponível (TPUs, muitas GPUs modernas) para melhor faixa e treinamento mais fácil.
  • Espere que alguns modelos (especialmente com exponenciais “agudas”) precisem de:
    • formulações estáveis (logsumexp, perdas “with logits”),
    • inicialização cuidadosa,
    • clipping de gradiente,
    • ou computação seletiva em float32 em submódulos sensíveis.

Problemas numéricos que você de fato vai depurar

NaNs e Infs

Fontes comuns:

  • overflow em exp (logits de atenção grandes demais).
  • divisão por zero (normalização sem epsilon, reduções vazias).
  • log(0) devido a probabilidades caírem para 0 por underflow.
  • operações inválidas como sqrt(negative) devido a arredondamento ou entradas ruins.

Passos típicos de depuração:

  • habilitar detecção de anomalias (específico do framework),
  • checar NaNs/Infs após blocos principais,
  • fazer clamp/cálculo em espaço log quando apropriado,
  • reduzir learning rate ou usar clipping de gradiente,
  • rodar temporariamente em float32 ou float64 para localizar sensibilidade.

Reprodutibilidade e não determinismo

Mesmo com sementes fixas, reduções paralelas em GPUs podem ser não determinísticas porque a adição em ponto flutuante não é associativa e o escalonamento de threads muda a ordem de soma. Treinamento distribuído (veja Fundamentos de Sistemas Distribuídos) pode amplificar isso.

Muitas bibliotecas oferecem “modo determinístico” a algum custo de desempenho.

Fundamentos de autodiff: computando gradientes de forma confiável

Treinar redes neurais depende de gradientes de uma perda escalar (L(\theta)) em relação aos parâmetros (\theta). Computar esses gradientes manualmente é trabalhoso e propenso a erros; diferenciação automática os computa programaticamente e de forma exata (até o arredondamento de ponto flutuante), aplicando sistematicamente a regra da cadeia.

Autodiff não é o mesmo que:

  • Diferenciação simbólica (symbolic differentiation) (manipulação algébrica),
  • Diferenciação numérica (numerical differentiation) (diferenças finitas; aproximada e frequentemente instável).

Grafos computacionais e a regra da cadeia

Autodiff representa um cálculo como um grafo de operações primitivas (soma, multiplicação, matmul, exp etc.). Cada operação sabe como computar:

  • seu valor de saída (forward),
  • e como propagar derivadas (backward).

Para uma composição (y = f(g(x))), a regra da cadeia diz:

[ \frac{dy}{dx} = \frac{dy}{dg}\cdot\frac{dg}{dx} ]

Autodiff aplica isso repetidamente pelo grafo.

Modo forward vs. modo reverse

Autodiff vem em dois “modos” principais:

Modo forward (JVP: produto Jacobiano-vetor)

O modo forward computa eficientemente (Jv), onde (J) é o Jacobiano das saídas em relação às entradas e (v) é um vetor. Ele é eficiente quando:

  • o número de entradas é pequeno,
  • o número de saídas é grande.

Conceitualmente, ele propaga derivadas das entradas para as saídas junto com os valores.

Modo reverse (VJP: produto vetor-Jacobiano)

O modo reverse computa eficientemente (v^T J) (frequentemente escrito como VJP). Para uma perda escalar (L), (v) é apenas 1, então o modo reverse produz o gradiente (\nabla_\theta L) de forma eficiente.

O modo reverse é a base da Retropropagação e é eficiente quando:

  • o número de saídas é pequeno (frequentemente 1 perda),
  • o número de entradas (parâmetros) é grande (milhões/bilhões).

Isso se encaixa perfeitamente no treinamento de deep learning.

Um exemplo mínimo (PyTorch)

import torch

# f(w) = (w^2 + 3w) summed over elements
w = torch.tensor([2.0, -1.0, 0.5], requires_grad=True)
loss = (w**2 + 3*w).sum()

loss.backward()
print(w.grad)  # derivative: 2w + 3

Autodiff constrói um grafo de operações e então executa diferenciação em modo reverse a partir de loss de volta para w.

Por que autodiff é “exata”, mas ainda é numérica

Autodiff fornece derivadas analiticamente corretas para o programa como escrito, mas os cálculos ainda são em precisão finita:

  • Gradientes podem sofrer underflow/overflow em baixa precisão.
  • Subtração e cancelamento podem reduzir a precisão dos gradientes.
  • Problemas mal condicionados levam a gradientes extremamente sensíveis.

Então preocupações de computação numérica se aplicam aos gradientes tanto quanto às passagens forward.

Verificação de gradientes (e suas armadilhas)

Uma checagem comum é comparar gradientes de autodiff com diferenças finitas:

[ \frac{\partial f}{\partial x} \approx \frac{f(x+h)-f(x-h)}{2h} ]

Mas em ponto flutuante:

  • Se (h) é pequeno demais, a subtração causa cancelamento catastrófico.
  • Se (h) é grande demais, o erro de aproximação domina.

Uma escolha típica é (h \sim 10^{-4}) para float32, mas depende da escala. Muitos frameworks fornecem utilitários de verificação de gradiente; use-os em entradas pequenas de brinquedo.

Aplicações práticas em fluxos de trabalho de IA/ML

Estabilidade e desempenho de treinamento

Escolhas de computação numérica afetam diretamente:

  • velocidade de convergência e acurácia final,
  • se o treinamento diverge,
  • throughput e uso de memória (precisão mista),
  • capacidade de escalar entre dispositivos (ordem de redução, reprodutibilidade).

Por exemplo, softmax estável e log-sum-exp são pré-requisitos para treinamento confiável de modelos baseados em atenção (veja Arquitetura Transformer).

Modelagem probabilística e computações em espaço log

Em ML probabilístico, multiplicar muitas probabilidades entra rapidamente em underflow. A prática padrão é trabalhar com probabilidades em log (log probabilities) e usar logsumexp para misturas e marginalização.

Programação científica e diferenciável

Autodiff permite diferenciar através de simulações, laços de otimização e modelos inspirados em física. Aqui, estabilidade numérica e condicionamento frequentemente são os principais gargalos: os gradientes podem estar corretos, mas ser inúteis se o cálculo subjacente é mal condicionado.

Regras práticas (vale memorizar)

  • Ponto flutuante é aproximado e não associativo; não confie que identidades algébricas se mantenham numericamente.
  • Prefira primitivas estáveis: logsumexp, log1p, perdas “with logits”, normalização estável.
  • Observe a magnitude: valores extremamente grandes/pequenos causam overflow/underflow e problemas de gradiente.
  • Precisão mista é poderosa, mas exige cuidado: mantenha reduções sensíveis/estados do otimizador em float32.
  • Autodiff é exata para o programa, mas gradientes ainda podem ser numericamente frágeis.
  • Ao depurar, tente:
    • alternar para float32/float64,
    • reduzir learning rate,
    • adicionar clipping de gradiente,
    • verificar NaNs/Infs após módulos-chave.

Conexões com outros tópicos centrais