↓ Ir para o conteúdo principal

Ajuste Não-Linear e Incertezas com SciPy

·4322 palavras·21 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 12: Esse Artigo

Um relatório de laboratório que diz “a energia de ativação é \(50.361\) J/mol” está incompleto, e de um jeito que importa. Não porque o número esteja errado, mas porque ele não vem acompanhado da única informação que permite usá-lo: quanto ele pode variar. Se a incerteza for de \(200\) J/mol, o valor distingue dois mecanismos de reação. Se for de \(8.000\), não distingue nada.

Todos os ajustes que esta série fez até aqui produziram números sem incerteza. O artigo de ajuste de curvas ajustou retas de calibração e módulos de elasticidade a dados explicitamente descritos como ruidosos, e reportou coeficientes secos. Não foi descuido: era o assunto errado na hora errada, e a hora certa é agora.

Este artigo fecha duas contas de uma vez. A primeira é essa: como extrair incertezas de um ajuste. A segunda é uma promessa feita literalmente na abertura do artigo de otimização, a de que ajustar uma curva é um problema de otimização disfarçado. Está na hora de mostrar que é mesmo, rodando os dois caminhos e comparando.

Por Que Ajuste Não-Linear e Incertezas Importam?
#

  • Toda medição tem erro: um parâmetro extraído de dados experimentais sem barra de erro não é um resultado científico, e sim um palpite bem formatado. Revistas costumam exigir a incerteza, e com razão.
  • Química e cinética: energia de ativação, constantes de velocidade, \(K_m\) e \(V_{max}\) de enzimas, constantes de equilíbrio. Praticamente todo parâmetro cinético ou termodinâmico vem de um ajuste não-linear a dados.
  • Decisão de engenharia: dimensionar com base num parâmetro ajustado exige saber a incerteza dele para escolher o coeficiente de segurança. Sem ela, ou se superdimensiona por medo, ou se subdimensiona por ignorância.
  • Modelos concorrentes: decidir se um modelo é melhor que outro passa por comparar parâmetros com suas incertezas. Dois valores que diferem por menos que a soma das barras de erro não diferem de fato.

Ajustar É Minimizar
#

O artigo de otimização abre afirmando que ajustar uma curva é otimizar. A afirmação é literal, e a demonstração é curta.

O método dos mínimos quadrados

Dado um modelo \(f(x; \theta)\) com parâmetros \(\theta\) e um conjunto de medidas \((x_i, y_i)\), o melhor ajuste é o vetor de parâmetros que minimiza a soma dos quadrados dos resíduos:

$$S(\theta) = \sum_{i=1}^{N} \left[y_i - f(x_i; \theta)\right]^2$$

“Ajustar uma curva” é, por definição, resolver um problema de minimização em \(\theta\), exatamente o tipo de problema que o scipy.optimize.minimize resolve.

O método é de Gauss, que o usou em 1801 para recalcular a órbita do asteroide Ceres a partir de umas poucas observações antes de ele se perder atrás do Sol, e acertou onde reaparecia. Ele só publicou o método em 1809, e nesse meio-tempo Legendre publicou o mesmo resultado, em 1805, gerando uma das disputas de prioridade mais conhecidas da matemática. A estatística nasceu, em boa parte, de astronomia.

Rodando os dois caminhos sobre o mesmo problema, um decaimento exponencial \(y = A e^{-kt}\) medido com ruído:

import numpy as np
from scipy.optimize import curve_fit, minimize


def modelo_exp(t, A, k):
    return A * np.exp(-k * t)


rng = np.random.default_rng(42)
t_dados = np.linspace(0, 5, 25)
y_dados = modelo_exp(t_dados, 2.5, 0.8) + rng.normal(0, 0.08, t_dados.size)


# caminho 1: escrever a soma dos quadrados na mão e minimizar
def soma_quadrados(params):
    A, k = params
    return np.sum((y_dados - modelo_exp(t_dados, A, k)) ** 2)


res_min = minimize(soma_quadrados, x0=[1.0, 1.0], method="Nelder-Mead",
                   options={"xatol": 1e-12, "fatol": 1e-12})

# caminho 2: curve_fit
popt, pcov = curve_fit(modelo_exp, t_dados, y_dados, p0=[1.0, 1.0])

print(f"minimize  : A = {res_min.x[0]:.10f}, k = {res_min.x[1]:.10f}")
print(f"curve_fit : A = {popt[0]:.10f}, k = {popt[1]:.10f}")
print(f"diferença : {np.abs(res_min.x - popt).max():.2e}")
minimize  : A = 2.4830688457, k = 0.8022242483
curve_fit : A = 2.4830688231, k = 0.8022242292
diferença : 2.26e-08

Oito casas decimais de concordância: é o mesmo problema. O curve_fit não faz nada de conceitualmente diferente do minimize: ele apenas sabe que o objetivo é uma soma de quadrados e usa isso a seu favor, com um algoritmo especializado (por padrão Levenberg-Marquardt, ou Trust Region Reflective quando há bounds) que explora essa estrutura.

E como é o mesmo problema, ele tem a mesma paisagem. A superfície da soma dos quadrados no plano \((A, k)\) é exatamente o tipo de mapa de contorno que o artigo de otimização usou:

Mapa de contorno da soma dos quadrados no plano dos dois parâmetros, com um vale único e alongado na diagonal. Uma estrela marca o mínimo, um círculo branco quase coincidente marca os valores verdadeiros usados para gerar os dados, e um quadrado no canto superior esquerdo marca o chute inicial, bem afastado do vale
A superfície da soma dos quadrados. O ajuste é o fundo desse vale, e o vale ser inclinado na diagonal já antecipa que os dois parâmetros são correlacionados.

Então quando usar minimize em vez de curve_fit?

Use curve_fit sempre que o problema for “tenho \((x_i, y_i)\) e um modelo \(f(x; \theta)\)”. É mais curto, mais rápido, e devolve a matriz de covariância, que o minimize não devolve.

Recorra ao minimize quando o objetivo não for uma soma de quadrados simples: quando houver restrições nos parâmetros que bounds não expresse, quando o critério for outro (máxima verossimilhança com uma distribuição não-gaussiana, mínimos desvios absolutos, regularização), ou quando os resíduos não puderem ser escritos como y - f(x).

O Que o curve_fit Dá a Mais: pcov
#

Aquele segundo valor de retorno que todo mundo ignora é a razão de existir deste artigo.

Matriz de covariância dos parâmetros

O curve_fit devolve (popt, pcov). A pcov é a matriz de covariância estimada dos parâmetros ajustados:

  • A diagonal contém as variâncias. A raiz quadrada de cada elemento diagonal é o desvio padrão (a “barra de erro”) do parâmetro correspondente: np.sqrt(np.diag(pcov)).
  • Os elementos fora da diagonal são as covariâncias entre pares de parâmetros. Divididos pelo produto dos desvios padrão, dão a correlação entre eles.

Ela sai de graça: o algoritmo precisa da matriz jacobiana para caminhar até o mínimo, e a covariância é essencialmente o inverso de \(J^T J\), reescalado pela variância residual. O trabalho já foi feito.

import numpy as np
from scipy.optimize import curve_fit


def modelo_exp(t, A, k):
    return A * np.exp(-k * t)


rng = np.random.default_rng(42)
t_dados = np.linspace(0, 5, 25)
y_dados = modelo_exp(t_dados, 2.5, 0.8) + rng.normal(0, 0.08, t_dados.size)
popt, pcov = curve_fit(modelo_exp, t_dados, y_dados, p0=[1.0, 1.0])

incertezas = np.sqrt(np.diag(pcov))
for nome, val, inc, verdade in zip(("A", "k"), popt, incertezas, (2.5, 0.8)):
    print(f"{nome} = {val:.4f} ± {inc:.4f}   (verdadeiro: {verdade})")

corr = pcov[0, 1] / np.sqrt(pcov[0, 0] * pcov[1, 1])
print(f"\ncorrelação entre A e k: {corr:+.4f}")
A = 2.4831 ± 0.0473   (verdadeiro: 2.5)
k = 0.8022 ± 0.0236   (verdadeiro: 0.8)

correlação entre A e k: +0.6482

Os dois valores verdadeiros caem dentro de uma barra de erro do ajustado, que é exatamente o que se espera quando tudo está funcionando.

E a correlação de \(+0{,}65\) não é ruído: ela é visível na figura anterior. O vale da soma dos quadrados é alongado na diagonal, e isso tem significado físico direto. Se o ajuste superestimar \(A\), ele precisa superestimar \(k\) também: uma amplitude inicial maior tem de decair mais rápido para passar pelos mesmos pontos no meio da curva. Os dois erros andam juntos.

Correlação alta é um alerta, não um erro

Uma correlação próxima de \(\pm 1\) significa que os parâmetros são quase indistinguíveis a partir desses dados: existe uma direção no espaço de parâmetros ao longo da qual a soma dos quadrados quase não muda. O ajuste “converge”, mas os valores individuais são pouco confiáveis mesmo que o modelo descreva bem os dados.

A solução normalmente não é numérica, é experimental: medir numa faixa mais ampla. Aqui, medir tempos mais longos separaria melhor \(A\) de \(k\), porque a cauda depende quase só de \(k\).

Com a incerteza em mãos, o ajuste deixa de ser uma curva e passa a ser uma faixa, e os resíduos passam a ser interpretáveis contra o ruído esperado:

Dois painéis empilhados. No de cima, os pontos medidos, a curva ajustada com uma faixa sombreada estreita em volta, e a curva verdadeira tracejada praticamente coincidente com o ajuste. No de baixo, os resíduos como hastes verticais alternando de sinal em torno do zero, quase todos contidos entre duas linhas pontilhadas que marcam o desvio padrão do ruído usado para gerar os dados
O ajuste com sua banda de 2σ. Abaixo, os resíduos: sem padrão visível e quase todos dentro do ±σ do ruído gerado: o modelo capturou a forma dos dados, e o que sobrou é ruído.

Vale a checagem de consistência: o desvio padrão dos resíduos dá \(0{,}0675\), contra o \(\sigma = 0{,}08\) que foi usado para gerar o ruído. Próximos, como devem ser. Um desvio residual muito menor que o ruído conhecido seria sinal de superajuste; muito maior, sinal de modelo errado.

Três Coisas Que Dão Errado
#

1. O Chute Inicial Importa, e Falha em Silêncio
#

O p0 do curve_fit é o mesmo chute inicial que o artigo de raízes e o de otimização discutiram: o ponto de onde a busca parte. E como a soma dos quadrados de um modelo não-linear pode ter vários mínimos locais, o chute decide para qual deles se converge.

Exemplo 1: Duas gaussianas e um chute infeliz

Enunciado: Um espectro tem dois picos, em \(x = 3\) e \(x = 7\). Ajuste a soma de duas gaussianas partindo de dois chutes iniciais diferentes.

Solução:

import numpy as np
from scipy.optimize import curve_fit

rng = np.random.default_rng(42)
rng.normal(0, 0.08, 25)  # os 25 ruídos de y_dados, sorteados antes com a mesma semente


def modelo_dois_picos(x, A1, c1, A2, c2):
    return A1 * np.exp(-((x - c1) ** 2) / 0.2) + A2 * np.exp(-((x - c2) ** 2) / 0.2)


x_picos = np.linspace(0, 10, 120)
y_picos = modelo_dois_picos(x_picos, 3.0, 3.0, 1.5, 7.0) + rng.normal(0, 0.05, x_picos.size)

for p0, rotulo in (([1, 2.5, 1, 7.5], "p0 razoável"),
                   ([1, 5.0, 1, 5.0], "p0 ruim (os dois picos no mesmo lugar)")):
    pp, _ = curve_fit(modelo_dois_picos, x_picos, y_picos, p0=p0, maxfev=20000)
    sq = np.sum((y_picos - modelo_dois_picos(x_picos, *pp)) ** 2)
    print(f"{rotulo:>40}: centros = {pp[1]:8.3f}, {pp[3]:8.3f}   SQ = {sq:.4f}")
                             p0 razoável: centros =    3.002,    6.999   SQ = 0.2239
  p0 ruim (os dois picos no mesmo lugar): centros = -104.687,  114.691   SQ = 74.8265

O segundo ajuste colocou os centros em \(-104\) e \(+114\), muito fora do intervalo onde existem dados. E o mais importante: não levantou exceção nenhuma. O curve_fit retornou normalmente, com uma pcov normal, e um usuário distraído seguiria em frente.

As duas curvas ajustadas, sobre os mesmos pontos:

Gráfico com uma nuvem de pontos formando dois picos distintos. Uma curva acompanha os dois picos de perto; outra, de cor diferente, é uma linha praticamente reta e achatada que ignora completamente a estrutura dos dados
Mesmo modelo, mesmos dados, mesmo algoritmo. A única diferença entre as duas curvas é o valor de p0, e nenhuma das duas execuções levantou erro.

A defesa é dupla: sempre passe um p0 informado (estimado a olho do gráfico, ou de um caso-limite conhecido), e sempre confira a soma dos quadrados final. Um valor muito maior que o esperado é o sinal de que a busca parou num vale errado, e ele custa uma linha.

2. Nem Todo Ponto Vale o Mesmo: sigma
#

Por padrão, o curve_fit supõe que todos os pontos têm a mesma incerteza. Isso é comum, mas está longe de universal: em muitas medições, o sinal fraco no fim da curva é bem mais ruidoso que o forte no começo.

Quando as incertezas individuais são conhecidas, informá-las muda o resultado:

import numpy as np
from scipy.optimize import curve_fit


def modelo_exp(t, A, k):
    return A * np.exp(-k * t)


rng = np.random.default_rng(42)
t_dados = np.linspace(0, 5, 25)
y_dados = modelo_exp(t_dados, 2.5, 0.8) + rng.normal(0, 0.08, t_dados.size)
rng.normal(0, 0.05, 120)  # os 120 ruídos do espectro de dois picos (Exemplo 1)

sigmas_pt = np.linspace(0.02, 0.30, t_dados.size)   # ruído cresce ao longo da curva
y_heter = modelo_exp(t_dados, 2.5, 0.8) + rng.normal(0, sigmas_pt)

p_sem, c_sem = curve_fit(modelo_exp, t_dados, y_heter, p0=[1.0, 1.0])
p_com, c_com = curve_fit(modelo_exp, t_dados, y_heter, p0=[1.0, 1.0],
                         sigma=sigmas_pt, absolute_sigma=True)

print(f"sem sigma : A = {p_sem[0]:.4f} ± {np.sqrt(c_sem[0,0]):.4f}")
print(f"com sigma : A = {p_com[0]:.4f} ± {np.sqrt(c_com[0,0]):.4f}")
sem sigma : A = 2.4908 ± 0.1187
com sigma : A = 2.4918 ± 0.0183

A estimativa central mal se mexe, mas a incerteza cai por um fator de seis e meio: ao dizer ao algoritmo quais pontos são confiáveis, ele para de diluir a informação boa com a ruim. A barra de erro menor é a barra de erro honesta: a de \(0{,}1187\) era pessimista, porque supunha que os pontos precisos eram tão ruins quanto os imprecisos.

absolute_sigma muda o significado da resposta
  • absolute_sigma=True: os valores em sigma são incertezas absolutas, na unidade dos dados. A pcov sai na escala real. Use quando você conhece a incerteza da sua medição: de uma calibração, da especificação do instrumento, de réplicas.
  • absolute_sigma=False (o padrão): os valores em sigma são tratados como pesos relativos. A pcov é reescalada pelo qui-quadrado reduzido, o que equivale a estimar a magnitude do ruído a partir da dispersão dos próprios resíduos.

A diferença é conceitual e importa na hora de reportar. Com o padrão, você está dizendo “não sei quanto vale meu ruído, estimei dos dados”, perfeitamente legítimo, e o que fizemos no primeiro exemplo. Com True, você está dizendo “eu sei quanto vale, e é isto”. Se você passar incertezas absolutas e esquecer absolute_sigma=True, o número reportado deixa de ser o que você acha que é.

3. Modelos Superparametrizados
#

O terceiro modo de falha é o mais silencioso, porque o ajuste “funciona”:

Exemplo 2: Dois parâmetros para uma única informação

Enunciado: Ajuste \(y = A \cdot B \cdot e^{-kt}\) aos mesmos dados do decaimento. O que acontece?

Solução:

import numpy as np
from scipy.optimize import curve_fit


def modelo_exp(t, A, k):
    return A * np.exp(-k * t)


rng = np.random.default_rng(42)
t_dados = np.linspace(0, 5, 25)
y_dados = modelo_exp(t_dados, 2.5, 0.8) + rng.normal(0, 0.08, t_dados.size)


def modelo_redundante(t, A, B, k):
    return A * B * np.exp(-k * t)


p_red, c_red = curve_fit(modelo_redundante, t_dados, y_dados, p0=[1.0, 1.0, 1.0])
print("parâmetros:", np.round(p_red, 4))
print("produto A·B =", round(p_red[0] * p_red[1], 4))
print("\nvariância de A:", f"{c_red[0, 0]:.3e}")
print("variância de k:", f"{c_red[2, 2]:.3e}")
parâmetros: [3.5267 0.7041 0.8022]
produto A·B = 2.4831

variância de A: 1.861e+28
variância de k: 8.195e-04

Repare no contraste. O produto \(A \cdot B = 2{,}4831\) é exatamente o mesmo \(A\) do primeiro ajuste, e \(k\) saiu igual com uma variância minúscula. Esses dois são perfeitamente determinados.

Mas a variância de \(A\) sozinho é \(1{,}9 \times 10^{28}\), um desvio padrão da ordem de \(10^{14}\). O algoritmo está dizendo, com todas as letras: este parâmetro não é determinável a partir destes dados. E não é mesmo: qualquer par \((A, B)\) com o produto certo ajusta igualmente bem. Não há informação nos dados que separe os dois.

Leia a diagonal da pcov antes de reportar qualquer parâmetro

Variâncias absurdamente grandes ou inf na diagonal costumam indicar um problema mal condicionado, como um modelo superparametrizado para os dados disponíveis: parâmetros que não são identificáveis separadamente, ou dados insuficientes para separá-los.

A saída certa não é numérica. É reparametrizar o modelo (aqui, trocar \(A \cdot B\) por um único parâmetro) ou medir mais, numa faixa que separe o que hoje está confundido. Aumentar maxfev ou trocar de algoritmo não resolve: o problema não é de convergência, é de falta de informação.

Aplicação: Energia de Ativação, e uma Surpresa
#

A equação de Arrhenius apareceu no artigo de otimização aplicada com \(E_a\) e \(A_0\) dados. No laboratório é o contrário: mede-se \(k\) em várias temperaturas, e os parâmetros são o que se quer descobrir.

$$ k(T) = A_0 e^{-E_a/(RT)} $$

Há duas rotas óbvias, e o folclore diz que uma é melhor. A rota clássica, anterior aos computadores, é linearizar: tomando o logaritmo,

$$ \ln k = \ln A_0 - \frac{E_a}{R}\cdot\frac{1}{T} $$

que é uma reta em \(1/T\), ajustável com régua e papel milimetrado. A rota moderna é ajustar o modelo não-linear direto, sem transformar nada.

Montei este exemplo esperando mostrar que a linearização é pior. A medição mostrou o contrário, e a explicação é mais interessante que a tese que eu queria defender:

import numpy as np
from scipy.optimize import curve_fit

R_GAS = 8.314
T_medidas = np.array([280.0, 300.0, 320.0, 340.0, 360.0, 380.0, 400.0])
k_limpo = 5e6 * np.exp(-50000.0 / (R_GAS * T_medidas))
rng_arr = np.random.default_rng(7)
k_medido = k_limpo * (1 + rng_arr.normal(0, 0.05, T_medidas.size))   # ruído de 5%


def arrhenius(T, A0, Ea):
    return A0 * np.exp(-Ea / (R_GAS * T))


p_sp, c_sp = curve_fit(arrhenius, T_medidas, k_medido, p0=[1e6, 4e4], maxfev=20000)
p_cp, c_cp = curve_fit(arrhenius, T_medidas, k_medido, p0=[1e6, 4e4],
                       sigma=0.05 * k_medido, absolute_sigma=True, maxfev=20000)
coef_lin, cov_lin = np.polyfit(1 / T_medidas, np.log(k_medido), 1, cov=True)

rotas = (
    ("não-linear SEM peso", p_sp[1], np.sqrt(c_sp[1, 1])),
    ("não-linear COM sigma = 5% de k", p_cp[1], np.sqrt(c_cp[1, 1])),
    ("linearizado (ln k vs 1/T)", -coef_lin[0] * R_GAS, np.sqrt(cov_lin[0, 0]) * R_GAS),
)
print("Energia de ativação (verdadeira: 50000 J/mol), 7 medidas com 5% de ruído:")
print(f"{'rota':>36} {'Ea (J/mol)':>12} {'erro':>7} {'incerteza':>11}")
for rota, Ea, inc in rotas:
    print(f"{rota:>36} {Ea:>12.0f} {abs(Ea - 50000) / 50000 * 100:>6.2f}% {inc:>11.0f}")
Energia de ativação (verdadeira: 50000 J/mol), 7 medidas com 5% de ruído:
                                rota   Ea (J/mol)    erro   incerteza
                 não-linear SEM peso        51961   3.92%         637
      não-linear COM sigma = 5% de k        49745   0.51%         444
           linearizado (ln k vs 1/T)        49754   0.49%         213

O ajuste não-linear sem peso é o pior dos três, errando por \(3{,}9\%\). O linearizado, supostamente ultrapassado, erra \(0{,}49\%\). E o não-linear ponderado erra \(0{,}51\%\), empatando com ele.

A lição não é sobre linearizar; é sobre o modelo de ruído

O que decide qual rota é correta é como o erro se distribui, e não a rota.

Aqui o ruído é multiplicativo: \(5\%\) do valor de \(k\). Como \(k\) varia por três ordens de grandeza ao longo da faixa de temperatura, o erro absoluto dos pontos de \(T\) alta é enorme comparado ao dos de \(T\) baixa. O ajuste não-linear sem peso minimiza resíduos absolutos, e portanto deixa os poucos pontos de \(T\) alta ditarem quase tudo.

E o logaritmo? Ruído multiplicativo vira ruído aditivo e de variância constante depois do \(\ln\), que é exatamente a hipótese sob a qual mínimos quadrados sem peso é o estimador correto. Linearizar aqui não é um atalho histórico: é a ponderação certa, obtida de graça.

A regra prática, então:

  • Erro absoluto constante (a balança erra \(0{,}1\) mg, não importa a massa) → ajuste o modelo direto, sem transformar.
  • Erro relativo constante (o instrumento erra \(5\%\) da leitura) → ou ajuste em escala log, ou passe sigma proporcional aos valores.

Os dois caminhos da segunda linha dão o mesmo resultado, como a tabela mostra. O que não dá certo é ignorar a questão.

Os dois espaços lado a lado tornam o mecanismo visível:

Dois painéis. À esquerda, a constante de velocidade contra a temperatura numa escala linear: os pontos de temperatura baixa ficam todos esmagados perto de zero, e só os dois ou três de temperatura alta têm valores grandes. À direita, o logaritmo da constante contra o inverso da temperatura: agora os sete pontos se distribuem de forma regular ao longo de uma reta, com espalhamento visualmente uniforme em torno dela
Os mesmos sete pontos, dois espaços. À esquerda, o ajuste sem peso é dominado pelos poucos pontos de k grande. À direita, o ruído fica uniforme ao longo da reta, que é a condição em que mínimos quadrados sem peso é o método certo.

Aplicação: Michaelis-Menten, e Quando Linearizar É Desastre
#

O exemplo anterior poderia sugerir que linearizar é sempre seguro. Não é, e o contraexemplo é o mais famoso da bioquímica.

A cinética enzimática de Michaelis-Menten relaciona a velocidade de reação \(v\) à concentração de substrato \([S]\):

$$ v = \frac{V_{max}[S]}{K_m + [S]} $$

A linearização clássica é a de Lineweaver-Burk, de 1934, que inverte os dois lados:

$$ \frac{1}{v} = \frac{K_m}{V_{max}}\cdot\frac{1}{[S]} + \frac{1}{V_{max}} $$

Uma reta em \(1/[S]\), cujo coeficiente linear dá \(1/V_{max}\) e cuja inclinação dá \(K_m/V_{max}\). Gerações de bioquímicos extraíram parâmetros assim, e o gráfico de duplo-recíproco continua em todo livro-texto.

Vamos comparar as duas rotas. Primeiro, com uma única simulação de ensaio:

import numpy as np
from scipy.optimize import curve_fit

S_medido = np.array([0.05, 0.08, 0.12, 0.20, 0.35, 0.60, 1.00, 1.80, 3.00, 5.00])  # mmol/L


def michaelis_menten(S, Vmax, Km):
    return Vmax * S / (Km + S)


def lineweaver_burk(S, v):
    """Ajusta 1/v contra 1/S por mínimos quadrados e desfaz a transformação."""
    coef = np.polyfit(1 / S, 1 / v, 1)
    Vmax = 1 / coef[1]
    return Vmax, coef[0] * Vmax


rng_mm = np.random.default_rng(42)
v_medido = michaelis_menten(S_medido, 2.0, 0.5) + rng_mm.normal(0, 0.04, S_medido.size)

p_mm, c_mm = curve_fit(michaelis_menten, S_medido, v_medido, p0=[1.0, 1.0])
inc_mm = np.sqrt(np.diag(c_mm))
Vmax_lb, Km_lb = lineweaver_burk(S_medido, v_medido)

print("UMA realização (semente 42):")
print(f"  verdade          : Vmax = {2.0:.4f}   Km = {0.5:.4f}")
print(f"  não-linear direto: Vmax = {p_mm[0]:.4f} ± {inc_mm[0]:.4f}"
      f"   Km = {p_mm[1]:.4f} ± {inc_mm[1]:.4f}")
print(f"  Lineweaver-Burk  : Vmax = {Vmax_lb:.4f}   Km = {Km_lb:.4f}")
UMA realização (semente 42):
  verdade          : Vmax = 2.0000   Km = 0.5000
  não-linear direto: Vmax = 1.9868 ± 0.0369   Km = 0.5139 ± 0.0308
  Lineweaver-Burk  : Vmax = 1.8538   Km = 0.4562

Lineweaver-Burk erra mais, mas não escandalosamente: \(7\%\) em \(V_{max}\). Alguém poderia concluir que a crítica é exagerada.

Não conclua nada daqui, porque uma realização não decide nada. Esse é um sorteio entre infinitos possíveis, e o que interessa é como o método se comporta em média. A pergunta certa é: se eu repetisse este ensaio duas mil vezes, como ficariam os erros?

import numpy as np
from scipy.optimize import curve_fit

S_medido = np.array([0.05, 0.08, 0.12, 0.20, 0.35, 0.60, 1.00, 1.80, 3.00, 5.00])  # mmol/L


def michaelis_menten(S, Vmax, Km):
    return Vmax * S / (Km + S)


def lineweaver_burk(S, v):
    """Ajusta 1/v contra 1/S por mínimos quadrados e desfaz a transformação."""
    coef = np.polyfit(1 / S, 1 / v, 1)
    Vmax = 1 / coef[1]
    return Vmax, coef[0] * Vmax


N_REPLICAS = 2000
rng_mc = np.random.default_rng(0)
res_nl, res_lb = [], []
for _ in range(N_REPLICAS):
    v = michaelis_menten(S_medido, 2.0, 0.5) + rng_mc.normal(0, 0.04, S_medido.size)
    if np.any(v <= 0.02):        # 1/v explodiria; descartado dos DOIS métodos
        continue
    pp, _ = curve_fit(michaelis_menten, S_medido, v, p0=[1.0, 1.0], maxfev=20000)
    res_nl.append(pp)
    res_lb.append(lineweaver_burk(S_medido, v))
res_nl, res_lb = np.array(res_nl), np.array(res_lb)

print(f"{len(res_nl)} realizações do mesmo ensaio:")
print(f"{'parâmetro':>10} {'rota':>18} {'RMS do erro':>13} {'viés médio':>12}")
for i, (nome, verd) in enumerate((("Vmax", 2.0), ("Km", 0.5))):
    for rot, arr in (("não-linear", res_nl), ("Lineweaver-Burk", res_lb)):
        rms = np.sqrt(np.mean((arr[:, i] - verd) ** 2)) / verd * 100
        print(f"{nome:>10} {rot:>18} {rms:>12.2f}% {np.mean(arr[:, i]) - verd:>+12.4f}")
2000 realizações do mesmo ensaio:
 parâmetro               rota   RMS do erro   viés médio
      Vmax         não-linear         1.90%      +0.0019
      Vmax    Lineweaver-Burk       333.13%      +0.1687
        Km         não-linear         6.27%      +0.0016
        Km    Lineweaver-Burk       589.92%      +0.0810

Agora sim. O erro quadrático médio de Lineweaver-Burk é de 333% em \(V_{max}\) e 590% em \(K_m\), contra \(1{,}9\%\) e \(6{,}3\%\) do ajuste direto. E há viés: o ajuste direto é praticamente não-viesado (\(+0{,}002\) num valor de \(2{,}0\)), enquanto Lineweaver-Burk superestima \(V_{max}\) sistematicamente em \(+0{,}17\).

A distribuição dos erros mostra de onde vêm esses números:

Dois histogramas lado a lado, um para cada parâmetro. Em ambos, a distribuição dos erros do ajuste direto é um pico estreito e simétrico centrado no zero. A do método de Lineweaver-Burk é larga, assimétrica e espalhada por dezenas de pontos percentuais, com uma barra alta acumulada na borda direita do gráfico representando as realizações cujo erro extrapolou a escala
Duas mil repetições do mesmo ensaio. O ajuste direto se concentra num pico estreito no zero; Lineweaver-Burk se espalha, e em 12% a 20% das realizações erra tanto que sai do gráfico.

Por Que a Inversão Estraga
#

A causa é mecânica, e a tabela de pesos a expõe. O ruído do ensaio é absoluto: \(\sigma_v = 0{,}04\) em qualquer ponto. Mas propagando esse erro pela transformação \(1/v\):

$$ \sigma_{1/v} \approx \frac{\sigma_v}{v^2} $$

O erro de cada ponto transformado é inversamente proporcional ao quadrado de \(v\). E os pontos de \([S]\) baixo, que têm \(v\) pequeno, são justamente os que ganham o maior \(1/[S]\), ou seja, os que mais alavancam a inclinação da reta:

\([S]\) \(v\) \(1/[S]\) \(\sigma_{1/v}\) Peso relativo correto
0,05 0,194 20,00 1,063 0,0001
0,12 0,417 8,33 0,230 0,0030
0,60 1,039 1,67 0,037 0,1150
1,80 1,553 0,56 0,017 0,5735
5,00 1,784 0,20 0,013 1,0000

A incerteza em \(1/v\) varia por um fator de 85 entre o melhor e o pior ponto. O ponto de \([S]=0{,}05\) deveria contar cerca de dez mil vezes menos que o de \([S]=5\). E o ajuste de reta sem peso trata os dois como igualmente confiáveis.

Pior: no espaço original, esse ponto ruim é apenas um ponto ruim. No espaço recíproco, ele é o ponto mais distante da origem, o de maior alavancagem sobre a inclinação. A transformação pegou o dado menos confiável e o promoveu a mais influente.

É o que a figura a seguir mostra, com as barras de erro propagadas:

Dois painéis. À esquerda, a curva de saturação com os dez pontos medidos e duas curvas ajustadas, uma passando bem pelos pontos e outra visivelmente abaixo deles na região de saturação. À direita, o mesmo conjunto no espaço recíproco: os pontos formam uma reta, mas as barras de erro crescem dramaticamente da esquerda para a direita, ficando enormes justamente nos pontos mais afastados que mais influenciam a inclinação
A mesma medição em dois espaços. À direita, as barras de erro propagadas: elas explodem exatamente nos pontos de maior alavancagem, e o ajuste de reta sem peso ignora isso.

Linearizar para visualizar é ótimo; para ajustar, quase nunca

O gráfico de Lineweaver-Burk continua útil como ferramenta de diagnóstico visual, porque é nele que os padrões de inibição enzimática (competitiva, não-competitiva, incompetitiva) se distinguem de relance pelo que muda: inclinação, intercepto, ou ambos.

O que não se deve mais fazer é extrair os parâmetros dele. A linearização foi uma necessidade histórica, de quando ajustar um modelo não-linear exigia iteração manual. Hoje custa uma linha, e a alternativa introduz erros de centenas de por cento.

A regra geral: transformar os dados transforma também os erros. Antes de ajustar em qualquer espaço que não seja o da medição, pergunte o que aconteceu com a incerteza de cada ponto naquela transformação. Se ela deixou de ser uniforme, ou você pondera, ou ajusta no espaço original.

Tabela-Resumo
#

Ferramenta Uso Observação
curve_fit(f, x, y, p0=...) Ajuste não-linear por mínimos quadrados Devolve (popt, pcov); é minimize sobre a soma dos quadrados
np.sqrt(np.diag(pcov)) Incerteza (1σ) de cada parâmetro Sem isso, o resultado não é um resultado científico
pcov[i, j] Correlação entre parâmetros Correlação alta = parâmetros pouco distinguíveis nesses dados
sigma= Incertezas individuais conhecidas Pode reduzir a barra de erro por um fator grande
absolute_sigma=True sigma é incerteza absoluta O padrão (False) trata sigma como peso relativo e reescala a pcov
bounds= Restringe a busca Útil quando o parâmetro tem significado físico (concentração > 0)
p0= Chute inicial Falha em silêncio quando ruim: confira sempre a soma dos quadrados final
Conceito Observação
Ajustar é minimizar curve_fit e minimize sobre \(\sum(y - f)^2\) dão o mesmo resultado
Variância enorme na pcov Modelo superparametrizado: o parâmetro não é identificável nesses dados
Modelo de ruído Erro absoluto → ajuste direto; erro relativo → escala log ou sigma proporcional
Linearizar Transforma os dados e os erros. Ótimo para ver; ruim para ajustar
Uma realização não decide Comparar métodos de ajuste exige repetir o experimento e olhar a distribuição
Resíduos Devem ficar da ordem do ruído conhecido, e sem padrão

Próximos Passos
#

Este artigo fecha o bloco de otimização da série: dos fundamentos do minimize, passando pelas aplicações com restrições, até o caso particular mais comum de todos, o de ajustar um modelo a dados medidos, com incerteza.

O que vem a seguir muda de eixo. Todos os métodos vistos até aqui, do cálculo de raízes ao ajuste, trabalham no espaço em que a medida foi feita: concentração contra tempo, velocidade contra temperatura, \(y\) contra \(x\). O próximo artigo faz uma pergunta diferente: e se o espaço mais natural para olhar esse sinal não for o do tempo, mas o das frequências que o compõem? A resposta é a transformada de Fourier, e ela reorganiza tudo o que se sabe sobre um sinal medido.

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

Relacionados

Otimização Aplicada com SciPy

·6968 palavras·33 minutos
Maximizar só a conversão de um reator empurra temperatura e tempo para o limite, e essa é a resposta certa para a pergunta errada. Quatro problemas reais com scipy.optimize: o reator, o alcance de um projétil com arrasto, uma viga com três restrições e um mix de produção.

Otimização com SciPy

·6492 palavras·31 minutos
Derivar e igualar a zero resolve a otimização de livro-texto. Com várias variáveis, restrições ou função sem derivada tratável, a conta trava. Veremos minimize, SLSQP e bounds do scipy.optimize, os mínimos locais e a busca global com differential_evolution.