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 se1 + xarredonda para 1.exp(x) - 1perde 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:
expem 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.
- formulações está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
- Questões numéricas frequentemente se manifestam durante otimização; veja Descida do Gradiente.
- Autodiff em modo reverse é o motor por trás da Retropropagação.
- Padrões práticos de código e ferramentas são cobertos em Programação para ML.
- Não determinismo e ordem de redução ficam mais significativos em escala; veja Fundamentos de Sistemas Distribuídos.