↓ Ir para o conteúdo principal

Polinômios com NumPy

·3513 palavras·17 minutos·
 Author
Autor
Francisco Bustamante
Químico, cientista de dados e programador Python.
Tabela de conteúdos
Do Símbolo ao Número - Este artigo faz parte de uma série de artigos.
Parte 5: Esse Artigo

Em 1959, o matemático inglês James H. Wilkinson investigava por que algoritmos de busca de raízes, considerados corretos no papel, se comportavam mal nos primeiros computadores digitais. Para testar uma hipótese, ele construiu um polinômio propositalmente inofensivo, hoje conhecido como polinômio de Wilkinson:

$$ (x-1)(x-2)(x-3)\cdots(x-20) $$

Grau 20, vinte raízes inteiras, de 1 a 20, todas bem separadas umas das outras. Não existe polinômio mais “educado” que esse, ou assim parecia. Wilkinson expandiu o produto em seus 21 coeficientes e alimentou um algoritmo numérico com eles.

E esses coeficientes são monstruosos. O termo independente é \(20!\), um inteiro de 19 dígitos; o maior de todos, o coeficiente de \(x^2\), passa de \(1{,}38 \times 10^{19}\). Guarde esse número: o maior inteiro que um float64 representa sem erro nenhum é \(2^{53} \approx 9 \times 10^{15}\), umas mil vezes menor. Ou seja, boa parte desses coeficientes sequer cabe exatamente num float: o estrago começa antes de qualquer algoritmo rodar.

O resultado deixou Wilkinson perplexo o suficiente para, décadas depois, descrever aquele momento como “a experiência mais traumática da minha carreira como analista numérico”.

O que aconteceu? Isso é o que vamos reconstruir neste artigo, com código de verdade, depois de aprender a manipular polinômios como objetos no NumPy.

Por Que Polinômios como Objetos Importam?
#

  • Análise numérica e computação científica: qualquer método que aproxima uma função por um polinômio (interpolação, quadratura, solvers de equações diferenciais) depende de saber que polinômios de grau alto podem amplificar erros pequenos de forma dramática, o próprio problema que Wilkinson descobriu.
  • Engenharia de controle: a estabilidade de um sistema linear (elétrico, mecânico) costuma se resumir a onde estão as raízes de um polinômio característico. Saber que essas raízes podem ser sensíveis a erros de medição dos coeficientes é uma preocupação prática.
  • Computação gráfica: curvas de Bézier e splines, usadas na maioria dos editores vetoriais e motores de jogos, são polinômios manipulados numericamente: avaliados, derivados, ajustados milhares de vezes por segundo.
  • Processamento de sinais: filtros digitais são descritos por uma razão de polinômios (a função de transferência); a localização das raízes do polinômio do denominador (os polos) determina se o filtro é estável.

Polinômios do SymPy, Revisitados no NumPy
#

No artigo Polinômios com SymPy vimos a classe Poly, que trata um polinômio como objeto (com métodos para coeficientes, grau, raízes, derivada e integral) em vez de uma mera expressão simbólica. O NumPy tem seu equivalente: numpy.polynomial.Polynomial. A diferença essencial é que aqui os coeficientes não são símbolos exatos, e sim números de ponto flutuante (float64), o que parece um detalhe irrelevante e, como veremos no fim deste artigo, não é.

Exemplo 1: Recriando um polinômio do artigo de SymPy

Enunciado: O artigo de SymPy usou \(Q(x) = x^2 - 25\) como exemplo recorrente. Construa o mesmo polinômio com numpy.polynomial.Polynomial e compare os coeficientes com os do SymPy.

Solução: Polynomial recebe os coeficientes em ordem crescente de grau, ou seja, o primeiro elemento é o termo independente, não o de maior grau (mais sobre isso já na próxima seção):

import sympy
from numpy.polynomial import Polynomial

x = sympy.Symbol("x")
Q_sym = sympy.Poly(x**2 - 25, x)
Q_np = Polynomial([-25, 0, 1])  # -25 + 0x + 1x^2

print("SymPy all_coeffs (decrescente):", Q_sym.all_coeffs())
print("NumPy .coef (crescente):", Q_np.coef)
SymPy all_coeffs (decrescente): [1, 0, -25]
NumPy .coef (crescente): [-25.   0.   1.]

Mesmo polinômio, duas ordens opostas de armazenar os coeficientes.

A Armadilha Nº 1: Ordem dos Coeficientes
#

Três convenções diferentes, mesmo polinômio

O NumPy tem, na verdade, duas formas de representar um polinômio, e nenhuma delas concorda com o SymPy:

Representação Ordem dos coeficientes Exemplo para \(Q(x)=x^2-25\)
numpy.poly1d (legado) Decrescente [1, 0, -25]
numpy.polynomial.Polynomial (moderna) Crescente [-25, 0, 1]
sympy.Poly.all_coeffs() Decrescente [1, 0, -25]

poly1d é a API antiga do NumPy: ainda funciona, mas é considerada legado; a documentação recomenda Polynomial para código novo. O risco está em misturá-las sem perceber, já que nenhuma convenção é a “certa”: passar a lista de coeficientes do jeito errado para a classe errada não gera erro nenhum e devolve um polinômio diferente, silenciosamente.

Como conferência, os dois construtores do NumPy chegam às mesmas raízes para o mesmo \(Q(x)\), apesar da ordem oposta dos coeficientes:

import numpy as np
from numpy.polynomial import Polynomial

Q_np = Polynomial([-25, 0, 1])  # -25 + 0x + 1x^2
p_legado = np.poly1d([1, 0, -25])  # decrescente
print("poly1d.roots:", p_legado.roots)
print("Polynomial.roots():", Q_np.roots())
poly1d.roots: [-5.  5.]
Polynomial.roots(): [-5.  5.]

Raízes, Inclusive as Complexas
#

Exemplo 2: Raízes de um polinômio de grau 3

Enunciado: O artigo de SymPy também usou \(A(x) = x^3 + 2x + 3\), que tem uma raiz real e duas complexas. Encontre as raízes com Polynomial.roots().

Solução:

from numpy.polynomial import Polynomial

A_np = Polynomial([3, 2, 0, 1])  # 3 + 2x + 0x^2 + 1x^3
print(A_np.roots())
[-1. +0.j         0.5-1.6583124j  0.5+1.6583124j]

O SymPy precisou de dois métodos separados para isso (all_roots() para todas as raízes, real_roots() só para as reais), porque, para o SymPy, raiz real e raiz complexa são objetos matematicamente distintos. Polynomial.roots() não faz essa distinção e devolve um único array numérico, real quando todas as raízes são reais e complexo (com parte imaginária zero nas raízes reais) quando há alguma raiz complexa, porque para o NumPy um polinômio é, antes de tudo, um array de coeficientes numéricos, a mesma filosofia de numpy.roots já usada em Resolvendo Equações Numericamente com SciPy, agora aplicada a polinômios como objetos.

Derivada, Integral e Divisão
#

Exemplo 3: Derivada, integral e divisão de polinômios

Enunciado: Calcule a derivada de \(Q(x)=x^2-25\) em \(x=2\), a integral de \(Q(x)\) em \(x=1\) (constante de integração zero), e o quociente e resto da divisão de \(A(x)=x^3+2x+3\) por \(Q(x)\).

Solução:

from numpy.polynomial import Polynomial

Q_np = Polynomial([-25, 0, 1])   # -25 + x^2
A_np = Polynomial([3, 2, 0, 1])  # 3 + 2x + x^3

print("Q'(2):", Q_np.deriv()(2))
print("integral de Q até 1:", Q_np.integ()(1))

quociente, resto = divmod(A_np, Q_np)
print("quociente:", quociente)
print("resto:", resto)
Q'(2): 4.0
integral de Q até 1: -24.666666666666668
quociente: 0.0 + 1.0·x
resto: 3.0 + 27.0·x

Os mesmos resultados do artigo de SymPy: \(Q^{\prime}(2)=4\), \(\int_0^1 Q dx = -\frac{74}{3} \approx -24{,}667\), e a divisão de \(A\) por \(Q\) dá quociente \(x\) e resto \(27x+3\). Como sempre em uma divisão, \(Q \cdot \text{quociente} + \text{resto} = A\).

divmod aqui é o divmod embutido do Python

divmod(A_np, Q_np) não é uma função própria do NumPy: é o divmod() embutido da linguagem. Funciona porque Polynomial implementa o método especial __divmod__ (junto com +, -, *, //, %, **, ==), devolvendo dois novos objetos Polynomial (quociente e resto). A documentação do NumPy sobre as classes de conveniência de polinômio lista explicitamente esses operadores sobrecarregados.

Onde Foi Parar a Exatidão do SymPy?
#

No artigo de introdução ao SymPy, os coeficientes inteiros de um polinômio eram exatos: sympy.Integer, sem nenhum arredondamento. Aqui, Q_np.coef já é numpy.float64 desde a construção. Para um polinômio de grau baixo como \(Q\) ou \(A\), isso não muda nada visível. Mas, à medida que o grau cresce, a diferença entre “exato” e “ponto flutuante” deixa de ser cosmética; é hora de voltar ao polinômio que abriu este artigo.

De Volta ao Polinômio de Wilkinson
#

O que Wilkinson descobriu tem nome, e vale defini-lo antes de reproduzir o experimento:

Mal-condicionamento

Um problema numérico é mal-condicionado quando uma variação minúscula nos dados de entrada produz uma variação desproporcionalmente grande na resposta, mesmo usando um algoritmo perfeitamente correto. Não é bug de código; é uma propriedade do problema em si.

Chegou a hora de reproduzir o experimento de Wilkinson, passo a passo.

Passo 1: os coeficientes exatos, com SymPy
#

Primeiro, expandimos \((x-1)(x-2)\cdots(x-20)\) simbolicamente (a mesma ferramenta usada nos artigos anteriores da série) para obter os 21 coeficientes sem nenhum erro de arredondamento:

import sympy

x = sympy.Symbol("x")
expr_wilkinson = sympy.prod([x - k for k in range(1, 21)])
poly_wilkinson_sym = sympy.Poly(expr_wilkinson, x)

coefs_dec = poly_wilkinson_sym.all_coeffs()  # decrescente, inteiros exatos
print("coeficiente de x^19:", coefs_dec[1])
print("termo constante (20!):", coefs_dec[-1])
coeficiente de x^19: -210
termo constante (20!): 2432902008176640000

Repare no tamanho desses números: o termo constante já é \(20!\), um inteiro de 19 dígitos. Todos os 21 coeficientes, neste ponto, ainda são exatos.

A figura a seguir mostra a magnitude de todos eles, em escala logarítmica, com a linha do maior inteiro que um float64 guarda sem erro nenhum:

import matplotlib.pyplot as plt
import numpy as np
import sympy

x = sympy.Symbol("x")
expr_wilkinson = sympy.prod([x - k for k in range(1, 21)])
coefs_dec = sympy.Poly(expr_wilkinson, x).all_coeffs()  # decrescente, inteiros exatos
modulos = [abs(int(c)) for c in reversed(coefs_dec)]     # crescente, ainda exatos

graus = np.arange(21)
fig, ax = plt.subplots(figsize=(8, 5))
ax.bar(graus, [float(m) for m in modulos], color="C0")
ax.axhline(2.0**53, color="C1", linestyle="--", linewidth=2,
           label=r"$2^{53}$: maior inteiro exato em float64")
ax.set_yscale("log")
ax.set_xlabel("grau do termo")
ax.set_ylabel(r"$|$coeficiente$|$")
ax.legend()

print(f"{sum(m > 2**53 for m in modulos)} dos 21 coeficientes passam de 2^53")
10 dos 21 coeficientes passam de 2^53

Gráfico de barras em escala logarítmica com a magnitude dos vinte e um coeficientes do polinômio de Wilkinson: as barras sobem do grau 20 até um pico perto do grau 2, e uma linha tracejada horizontal marca dois elevado a cinquenta e três, com dez das barras acima dela
Dez dos vinte e um coeficientes passam de 2⁵³, o maior inteiro que um float64 representa exatamente. O estrago começa na conversão, antes de qualquer algoritmo rodar.

Dez dos vinte e um coeficientes estão acima da linha. Cada um deles já chega ao float64 com erro, não por culpa de nenhum algoritmo, mas porque o formato não comporta o número.

Passo 2: convertendo para float64, e já aparece erro
#

Agora vem a parte que qualquer Polynomial do NumPy exige: converter esses inteiros exatos para float64. Isso, sozinho, já é uma perturbação:

import sympy
from numpy.polynomial import Polynomial

x = sympy.Symbol("x")
expr_wilkinson = sympy.prod([x - k for k in range(1, 21)])
coefs_dec = sympy.Poly(expr_wilkinson, x).all_coeffs()  # decrescente, inteiros exatos

coefs_wilkinson = [float(c) for c in reversed(coefs_dec)]
W = Polynomial(coefs_wilkinson)

Com o polinômio construído, calculamos as raízes e comparamos com os valores esperados (os inteiros de 1 a 20):

import numpy as np
import sympy
from numpy.polynomial import Polynomial

x = sympy.Symbol("x")
expr_wilkinson = sympy.prod([x - k for k in range(1, 21)])
coefs_dec = sympy.Poly(expr_wilkinson, x).all_coeffs()  # decrescente, inteiros exatos
coefs_wilkinson = [float(c) for c in reversed(coefs_dec)]
W = Polynomial(coefs_wilkinson)

raizes = W.roots()
erro_max = np.max(np.abs(np.sort(raizes.real) - np.arange(1, 21)))
print(f"erro máximo, sem nenhuma perturbação deliberada: {erro_max:.4f}")
print("raízes calculadas para os valores esperados 14 e 15:")
print(np.sort_complex(raizes)[13:15])
erro máximo, sem nenhuma perturbação deliberada: 0.5185
raízes calculadas para os valores esperados 14 e 15:
[14.518475-0.20853241j 14.518475+0.20853241j]

Vale reler essa saída com atenção, porque ela é mais dramática do que parece: as raízes deixaram de ser reais. Onde deveriam estar dois números inteiros, \(14\) e \(15\), está um par de complexos conjugados, \(14{,}518 \pm 0{,}209i\).

E nada disso foi perturbado de propósito. O único “insulto” ao polinômio foi converter seus coeficientes inteiros exatos para float64, o que é inevitável, já que é o que qualquer Polynomial do NumPy exige.

Cuidado com .roots().real

É tentador escrever W.roots().real para “limpar” o resultado. Mas .real descarta silenciosamente a parte imaginária: o par conjugado acima vira dois valores idênticos, \(14{,}518\) e \(14{,}518\), e o leitor conclui que as raízes apenas “colidiram”. A informação mais importante, que elas saíram do eixo real, some sem aviso.

Quando quiser só as raízes reais de verdade, filtre em vez de truncar: raizes[np.abs(raizes.imag) < tol].real.

Para medir o quão frágil isso é, vale uma experiência que não perturba nada de propósito: construir o mesmo polinômio por dois caminhos diferentes. Um é o que acabamos de usar (expandir com SymPy, converter para float64); o outro é o Polynomial.fromroots, que multiplica os fatores diretamente em ponto flutuante.

import numpy as np
import sympy
from numpy.polynomial import Polynomial

x = sympy.Symbol("x")
expr_wilkinson = sympy.prod([x - k for k in range(1, 21)])
coefs_dec = sympy.Poly(expr_wilkinson, x).all_coeffs()  # decrescente, inteiros exatos
coefs_wilkinson = [float(c) for c in reversed(coefs_dec)]
W = Polynomial(coefs_wilkinson)

W_via_sympy = W
W_via_fromroots = Polynomial.fromroots(np.arange(1, 21))

print("os coeficientes são iguais?", np.allclose(W_via_sympy.coef, W_via_fromroots.coef))
print("maior diferença relativa:",
      np.max(np.abs(W_via_sympy.coef - W_via_fromroots.coef) /
             np.maximum(np.abs(W_via_sympy.coef), 1)))
print()
print("raízes 14/15, via SymPy    :", np.sort_complex(W_via_sympy.roots())[13:15])
print("raízes 14/15, via fromroots:", np.sort_complex(W_via_fromroots.roots())[13:15])
os coeficientes são iguais? True
maior diferença relativa: 1.5911824568028567e-16

raízes 14/15, via SymPy    : [14.518475-0.20853241j 14.518475+0.20853241j]
raízes 14/15, via fromroots: [14.51819694-0.20459637j 14.51819694+0.20459637j]

Leia esses números com calma. Os coeficientes dos dois polinômios diferem, no máximo, em \(1{,}6\times10^{-16}\) relativo, ou seja, em um único bit do último dígito do float64. É a menor diferença que existe entre dois números diferentes nessa representação; menos que isso, seriam idênticos.

E as raízes diferem na quarta casa decimal: a parte imaginária vai de \(0{,}2085\) para \(0{,}2046\), quase \(2\%\).

Nenhum dos dois caminhos está errado, e nenhum algoritmo falhou. Simplesmente não existe, em float64, uma resposta “a” para a pergunta “quais são as raízes desse polinômio”: a pergunta é sensível demais para o formato em que ela foi feita. É esse fenômeno que tem nome, e é o que vamos perturbar de propósito a seguir para medir.

Passo 3: a perturbação clássica de Wilkinson
#

Agora, deliberadamente, diminuímos um único coeficiente, o de \(x^{19}\), que vale exatamente \(-210\), em apenas \(2^{-23} \approx 1{,}19\times10^{-7}\). Esse expoente é o épsilon de máquina da precisão simples, o formato de 32 bits com 23 bits de mantissa que detalhamos no artigo sobre IEEE 754. É um erro menor que o de qualquer medição experimental razoável.

import numpy as np
import sympy
from numpy.polynomial import Polynomial

x = sympy.Symbol("x")
expr_wilkinson = sympy.prod([x - k for k in range(1, 21)])
coefs_dec = sympy.Poly(expr_wilkinson, x).all_coeffs()  # decrescente, inteiros exatos
coefs_wilkinson = [float(c) for c in reversed(coefs_dec)]
W = Polynomial(coefs_wilkinson)

coefs_perturbados = np.array(coefs_wilkinson, dtype=float)
coefs_perturbados[19] -= 2**-23
W_perturbado = Polynomial(coefs_perturbados)

Passo 4: o que aconteceu com as raízes
#

Com o polinômio perturbado construído, recalculamos as raízes e comparamos com as originais:

import numpy as np
import sympy
from numpy.polynomial import Polynomial

x = sympy.Symbol("x")
expr_wilkinson = sympy.prod([x - k for k in range(1, 21)])
coefs_dec = sympy.Poly(expr_wilkinson, x).all_coeffs()  # decrescente, inteiros exatos
coefs_wilkinson = [float(c) for c in reversed(coefs_dec)]
W = Polynomial(coefs_wilkinson)

coefs_perturbados = np.array(coefs_wilkinson, dtype=float)
coefs_perturbados[19] -= 2**-23
W_perturbado = Polynomial(coefs_perturbados)

raizes_perturbadas = W_perturbado.roots()
max_parte_imaginaria = np.max(np.abs(raizes_perturbadas.imag))
raizes_reais = raizes_perturbadas[np.abs(raizes_perturbadas.imag) < 1e-6].real
raiz_que_era_20 = raizes_reais[np.argmin(np.abs(raizes_reais - 20))]

print(f"maior parte imaginária surgida: {max_parte_imaginaria:.4f}")
print(f"raiz que era 20 virou: {raiz_que_era_20:.4f}")
maior parte imaginária surgida: 2.8127
raiz que era 20 virou: 20.8469

Dez das vinte raízes deixam de ser reais. A raiz que era exatamente 20 sobe para \(\approx 20{,}85\), um desvio de \(4{,}2\%\) causado por uma perturbação de \(10^{-7}\) em um único coeficiente, entre vinte.

Passo 5: visualizando a dispersão das raízes
#

O gráfico a seguir coloca lado a lado as raízes originais, todas reais (círculos, alinhadas no eixo x), e as raízes perturbadas, espalhadas pelo plano complexo (x):

import matplotlib.pyplot as plt
import numpy as np
import sympy
from numpy.polynomial import Polynomial

x = sympy.Symbol("x")
expr_wilkinson = sympy.prod([x - k for k in range(1, 21)])
coefs_dec = sympy.Poly(expr_wilkinson, x).all_coeffs()  # decrescente, inteiros exatos
coefs_wilkinson = [float(c) for c in reversed(coefs_dec)]
W = Polynomial(coefs_wilkinson)

coefs_perturbados = np.array(coefs_wilkinson, dtype=float)
coefs_perturbados[19] -= 2**-23
raizes_perturbadas = Polynomial(coefs_perturbados).roots()

fig, ax = plt.subplots(figsize=(8, 7))
ax.scatter(
    np.arange(1, 21), np.zeros(20),
    label="raízes teóricas (inteiros 1 a 20)", zorder=3, s=60,
)
ax.scatter(
    raizes_perturbadas.real, raizes_perturbadas.imag,
    marker="x", label="raízes após perturbar 1 coeficiente em $2^{-23}$",
    zorder=3, s=60,
)
ax.axhline(0, color="black", linewidth=0.8, zorder=1)
ax.set_xlabel("Parte real")
ax.set_ylabel("Parte imaginária")
ax.set_title("Sensibilidade das raízes do polinômio de Wilkinson", pad=12)
ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.12), ncol=1)

Gráfico de dispersão no plano complexo: as vinte raízes originais do polinômio de Wilkinson, todas reais e alinhadas no eixo x, contra as raízes perturbadas, com metade delas formando pares de pontos simétricos acima e abaixo do eixo real
Uma perturbação de 2⁻²³ em um único coeficiente, entre vinte, joga dez raízes para fora do eixo real.

Isso é o que Wilkinson viu em 1959, e o motivo de sua reação não ter sido exagero.

Por Que Isso Acontece
#

Até aqui o artigo mostrou que acontece. Falta o porquê, e ele cabe numa fórmula.

Perturbe o coeficiente \(a_k\) de uma quantidade pequena \(\delta\). Cada raiz \(x_j\) se desloca, em primeira ordem, de:

$$ \frac{\partial x_j}{\partial a_k} = -\frac{x_j^{k}}{p'(x_j)} $$

A dedução é curta: se \(p(x_j) = 0\) e perturbamos \(a_k\), a nova raiz satisfaz \(p(x_j + \Delta) + \delta (x_j+\Delta)^k = 0\). Expandindo em Taylor e ficando na primeira ordem, \(p^{\prime}(x_j)\Delta + \delta x_j^k \approx 0\), de onde sai a razão acima.

Essa fração explica tudo, porque seus dois pedaços puxam em direções opostas conforme \(j\) cresce:

  • O numerador \(x_j^{19}\) explode. Para \(x_1 = 1\) vale 1; para \(x_{20} = 20\) vale \(5{,}2 \times 10^{24}\).
  • O denominador \(p^{\prime}(x_j)\) despenca no meio. Como \(p^{\prime}(x_j) = \prod_{i \neq j}(x_j - x_i)\), ele é o produto das 19 distâncias de \(x_j\) às outras raízes. Nas pontas (\(j=1\) ou \(j=20\)) esse produto é \(19! \approx 10^{17}\), enorme. No meio (\(j \approx 10\)), metade das diferenças é pequena e o produto cai para \(10^{12}\).
import sympy
from numpy.polynomial import Polynomial

x = sympy.Symbol("x")
expr_wilkinson = sympy.prod([x - k for k in range(1, 21)])
coefs_dec = sympy.Poly(expr_wilkinson, x).all_coeffs()  # decrescente, inteiros exatos
coefs_wilkinson = [float(c) for c in reversed(coefs_dec)]
W = Polynomial(coefs_wilkinson)

dW = W.deriv()
print(f"{'j':>3} {'|p_linha(x_j)|':>15} {'x_j^19':>12} {'sensibilidade':>15}")
for j in (1, 2, 5, 10, 16, 20):
    xj = float(j)
    print(f"{j:>3} {abs(dW(xj)):>15.2e} {xj**19:>12.2e} {abs(xj**19 / dW(xj)):>15.2e}")
  j  |p_linha(x_j)|       x_j^19   sensibilidade
  1        1.22e+17     1.00e+00        8.22e-18
  2        6.40e+15     5.24e+05        8.19e-11
  5        3.14e+13     1.91e+13        6.08e-01
 10        1.32e+12     1.00e+19        7.60e+06
 16        3.14e+13     7.56e+22        2.41e+09
 20        1.22e+17     5.24e+24        4.31e+07

A última coluna varia 27 ordens de grandeza. A raiz \(x_1 = 1\) é essencialmente imune (\(8{,}2 \times 10^{-18}\)); a raiz \(x_{16}\) é \(2{,}4 \times 10^{9}\) vezes mais sensível que a perturbação aplicada.

A figura a seguir mostra essa coluna inteira, uma barra por raiz:

Gráfico de barras em escala logarítmica com a sensibilidade de cada uma das vinte raízes: uma escada que sobe de dez elevado a menos dezoito na raiz 1 até cerca de dez elevado a nove nas raízes centrais, com uma linha tracejada horizontal marcando o limiar acima do qual o deslocamento passa de meia unidade
A sensibilidade de cada raiz ao coeficiente de x¹⁹, em escala log. As raízes acima da linha tracejada são as que a perturbação de 2⁻²³ desloca visivelmente, e são exatamente as que quebram no experimento.

Compare essa figura com o resultado do experimento: as raízes que sobreviveram inteiras são as pequenas, e as que viraram pares complexos são as do meio-para-cima. A figura previu quais quebrariam, antes de rodar o roots().

Isso é mal-condicionamento, não um algoritmo ruim

A distinção é a moral do artigo. O numpy.roots calcula os autovalores da matriz companheira com um algoritmo estável em sentido de norma (backward stable): o resultado que ele devolve é a resposta exata de um problema muito próximo do que você passou. Ele não erra: ele responde com precisão a uma pergunta ligeiramente diferente da sua.

Em problemas bem-condicionados, isso basta: resposta exata de um problema próximo é uma resposta próxima. No polinômio de Wilkinson, não basta, porque “próximo nos coeficientes” e “próximo nas raízes” deixam de ser a mesma coisa. Trocar de algoritmo não ajudaria; o problema está na pergunta, não em quem responde.

Não é Só Proximidade: Multiplicidade Também
#

Poderia parecer que o problema é ter raízes “próximas demais”. O extremo oposto mostra que não:

import numpy as np
from numpy.polynomial import Polynomial

P_mult = Polynomial.fromroots(np.ones(20))   # (x-1)^20
raizes_mult = P_mult.roots()
print(f"raio do espalhamento em torno de 1: {np.max(np.abs(raizes_mult - 1)):.4f}")
print("quantas deixaram de ser reais:", int((np.abs(raizes_mult.imag) > 1e-8).sum()))
raio do espalhamento em torno de 1: 0.4352
quantas deixaram de ser reais: 18

\((x-1)^{20}\) tem uma raiz, repetida 20 vezes. O roots() devolve 20 valores espalhados num círculo de raio \(0{,}44\) em torno de 1, e 18 deles nem são reais. Faz sentido pela mesma fórmula: numa raiz múltipla, \(p^{\prime}(x_j) = 0\) exatamente, e a sensibilidade é infinita.

A Lição Prática
#

Raízes de grau alto parecem impossíveis de domar, mas a fórmula sugere o contrário. Ela aponta o culpado, e o culpado é evitável:

Não expanda para achar raízes

A perda acontece no momento em que os fatores \((x-1)(x-2)\cdots\) viram uma lista de coeficientes. Antes disso, as raízes estavam escritas na cara. Regras práticas:

  • Mantenha a forma fatorada sempre que ela existir. Polynomial.fromroots e roots() são operações inversas, mas o caminho de ida perde informação.
  • Prefira outra base. A base de potências \(1, x, x^2, \ldots\) é péssima em intervalos longe da origem. O próximo artigo mostra que o mesmo polinômio, escrito na base de Chebyshev com o domínio mapeado para \([1, 20]\), fica milhões de vezes menos sensível.
  • Se o problema vier de uma matriz (autovalores, estabilidade de sistemas), resolva a matriz diretamente em vez de montar e resolver o polinômio característico. O caminho pelo polinômio destrói condicionamento de graça.

Ou seja: o mal-condicionamento de Wilkinson não é uma propriedade do conjunto de raízes \(\{1, \ldots, 20\}\). É uma propriedade de representá-lo como lista de coeficientes na base de potências.

Tabela-Resumo
#

Operação Sintaxe Observação
Construção Polynomial([c0, c1, ..., cn]) Ordem crescente de grau
Construção (legado) numpy.poly1d([cn, ..., c1, c0]) Ordem decrescente, evitar em código novo
Raízes .roots() Array real se todas as raízes são reais; complexo se houver alguma complexa
Derivada .deriv() Retorna outro Polynomial
Integral .integ() Constante de integração zero por padrão
Divisão divmod(A, B) Retorna (quociente, resto)
Coeficientes exatos Não existe Todo coeficiente é float64; mal-condicionado em grau alto (Wilkinson)

Próximos Passos
#

Este artigo cobriu polinômios como objetos já conhecidos, construídos a partir de coeficientes ou raízes que já tínhamos de antemão. Mas a maior parte dos dados do mundo real não vem com um polinômio anexado: vem como uma nuvem de pontos experimentais, com ruído, e cabe a nós encontrar o polinômio que melhor descreve esses pontos. O SymPy não faz isso: não existe regressão simbólica. No próximo artigo, usamos Polynomial.fit() para ajustar curvas a dados reais de química, física e engenharia, e vemos por que grau alto nem sempre é ajuste melhor: o fenômeno de Runge, uma espécie de primo do mal-condicionamento de Wilkinson, mas para interpolação em vez de raízes.

Do Símbolo ao Número - Este artigo faz parte de uma série de artigos.
Parte 5: Esse Artigo

Relacionados