Logo NascimentoIgor Nascimento / Blog
Séries temporais

Nahas: uma bancada para prever preço em três frameworks, e o que ela cobra em troca

Uma plataforma para treinar LSTM e Transformer em Keras, TensorFlow e PyTorch atrás de uma interface só, com normalização multi-escala, busca em grade em várias GPUs, fine-tuning com backup e previsão ao vivo pela Binance. Uma conversa sobre o que esse desenho entrega, o que ele custa em complexidade, e sobre os problemas de metodologia que a própria bancada ajudou a pegar e corrigir antes de qualquer resultado sair, ao preço de muitas execuções perdidas.

Séries temporaisDeep Learning21 min de leituraIgor Nascimento

O que o Nahas é, e por que ele existe

O Nahas é a bancada de experimentos da minha dissertação: uma plataforma para prever séries financeiras, com foco em criptomoedas, usando LSTM e Transformer. Ele treina os dois tipos de modelo em três frameworks (Keras, TensorFlow e PyTorch) atrás de uma interface única, roda busca em grade de hiperparâmetros em várias GPUs ao mesmo tempo, faz fine-tuning com backup e comparação automática, persiste tudo em MySQL e executa previsão ao vivo com dados da Binance. Também implementa quatro estratégias de normalização, entre elas uma variante multi-escala inspirada no EvoMSN, de Qin e colegas (2024), que escolhe periodicidades por Fourier e normaliza cada fatia da janela separadamente.

Esse texto é sobre o que esse desenho entrega e o que ele cobra. Uma bancada de pesquisa tem um objetivo diferente de um produto: ela precisa tornar comparável o que normalmente não é. Trocar o framework sem trocar o experimento, trocar a normalização sem trocar o modelo, rodar cinquenta configurações numa noite e acordar com um relatório que diz, para cada uma, se ela tem habilidade de verdade ou só aprendeu a copiar ontem. Cada uma dessas conveniências tem um preço em complexidade, e boa parte das decisões do Nahas é a escolha de pagar ou não esse preço. E, como toda bancada, ela também serviu para pegar os meus próprios erros de metodologia antes que eles saíssem dela, o que custou caro de outra forma: muitas execuções perdidas.

O ganho principal: trocar o framework sem trocar o experimento

A decisão que organiza tudo é que o modelo é um Trainer intercambiável atrás de uma interface comum: treinar, salvar, carregar, prever, avaliar e validar têm a mesma assinatura em Keras, TensorFlow e PyTorch. O que cada framework faz de diferente fica escondido atrás dela. O que isso compra é que a pipeline inteira, do carregamento dos dados ao relatório de métricas, é escrita uma vez. O mesmo janelamento, a mesma normalização, as mesmas baselines, a mesma persistência, a mesma previsão ao vivo. Quando eu quero saber se um resultado é do modelo ou do framework, troco um campo no config e rodo de novo. Sem a interface, cada comparação entre frameworks seria uma reimplementação, e cada reimplementação seria uma fonte nova de diferença que não é a que eu quero medir.

O preço é o que uma interface única sempre custa: o menor denominador comum. O que um framework faz de especial e os outros não fazem precisa ou ser escondido ou ficar de fora. E o preço maior apareceu na infraestrutura, não no código. TensorFlow e PyTorch não convivem no mesmo ambiente por causa das versões de CUDA, então os requisitos tiveram que ser separados em três arquivos: o que o código importa, os extras opcionais por framework e o congelamento exato do ambiente em que os experimentos rodaram. O ambiente da dissertação não tinha PyTorch instalado, e deixo isso escrito, porque a comparação entre frameworks só é honesta se quem lê sabe qual deles produziu cada número.

Ganha

  • Uma pipeline só, do dado ao relatório, para três frameworks
  • Comparação entre LSTM e Transformer com tudo o mais fixo
  • Previsão ao vivo que não sabe qual framework treinou o modelo
  • A fábrica de trainers diz qual extra instalar quando falta um

Paga

  • Menor denominador comum entre os frameworks
  • Três arquivos de requisitos e dois ambientes CUDA
  • O trainer PyTorch ainda não tem paridade de mini-batch com o Keras
  • Cada recurso novo precisa ser pensado três vezes

Normalização como estágio da pipeline, não como pré-processamento

Prever preço um passo à frente tem uma característica que domina tudo: a série é fortemente autocorrelacionada. O preço de amanhã é quase o de hoje. Isso significa que a escolha de como normalizar a janela de entrada e o alvo pesa mais do que a arquitetura da rede, e foi em torno disso que o framework foi desenhado. A normalização não é um script que roda antes do treino. É um estágio com contexto próprio, que sabe desfazer a transformação no fim, e que decide inclusive quantos canais o modelo vai receber.

Há quatro estratégias. A global ajusta um scaler no treino e aplica em tudo. A local normaliza janela a janela, com modos para X (z-score, min-max, robusto) e para y (relativo ao último valor, z-score do alvo, min-max do alvo). A EvoMSN faz a normalização multi-escala: seleciona as periodicidades dominantes por FFT, fatia a janela por período, normaliza cada fatia por média e desvio, treina regressores que preveem as estatísticas futuras do alvo a partir das estatísticas da entrada, e no fim combina as previsões de cada escala com pesos proporcionais à amplitude de cada frequência na janela. A EvoMSN-like dispensa os regressores e usa a última fatia como proxy do futuro.

A ressalva que faço logo de cara

O que o Nahas implementa é uma variante offline inspirada no EvoMSN. O método original alterna, durante o treino, atualizações entre o modelo de sequência e o módulo estatístico, numa otimização evolutiva online. Isso não está implementado. Chamar de "EvoMSN" sem essa ressalva seria reivindicar um método que não é o que roda.

O que esse desenho compra é a comparação limpa entre normalizações: o mesmo modelo, a mesma janela, o mesmo relatório, e só a estratégia muda. O que ele cobra é que o número de canais da entrada passa a depender da estratégia, porque a normalização local precisa da série do alvo dentro de X para normalizar o alvo pela janela. Essa dependência é real e precisa ser controlada explicitamente, o que me leva à parte da bancada que mais valeu o trabalho.

O que a bancada faz sozinha a cada treino

Em previsão de preço, R² e RMSE de nível não dizem se o modelo tem habilidade. Em série autocorrelacionada, repetir a última observação já dá R² próximo de 1. Um modelo que faz só isso parece excelente em qualquer métrica de nível, e vai parecer excelente com convicção. Por isso o Nahas não entrega um RMSE solto. Cada treino, sem opção de desligar, roda e grava no relatório:

O que isso muda na prática

A pergunta que o relatório responde não é "qual o R²". É "ele bate a persistência com significância, e o retorno previsto se correlaciona com o realizado?". Esses dois números são lidos antes de qualquer outro. Se o framework não responde isso a cada treino, ele vai produzir números bonitos que não significam nada. O custo é um relatório mais longo e alguns segundos a mais por execução. É o melhor negócio do projeto.

Busca em grade em várias GPUs, e o que ela esconde

A grade roda em paralelo com um pool de processos. Cada processo isola uma GPU via CUDA_VISIBLE_DEVICES e a enxerga como índice zero, e tem que reconectar ao MySQL por conta própria, porque dois processos herdando a mesma conexão fazem o driver devolver "Packet sequence number wrong". A quantidade de jobs por placa vem de uma estimativa de memória por configuração, uma heurística declarada como tal, com limites de 0,8 a 10 GB e uma margem de segurança. Há timeout de 30 segundos para a inicialização do framework e de duas horas por treino, implementados por sinal do sistema, o que significa que só funcionam em Unix e só na thread principal. Deixo isso listado como limitação em vez de fingir que é portátil.

Quando nenhuma combinação cabe nos recursos disponíveis, um detector de impasse explica o que ajustar, em vez de girar em vazio consumindo um núcleo. Cada combinação recebe uma cópia profunda da configuração e um identificador próprio de execução, para que duas combinações nunca escrevam na mesma pasta nem mexam no mesmo dicionário aninhado. E a grade pontua na validação, não no teste: escolher hiperparâmetro pela métrica do teste transforma o conjunto de relato em conjunto de seleção, e o teste continua disponível só como relato.

O que a grade compra é óbvio: dezenas de configurações numa noite, cada uma com o relatório completo de baselines. O que ela cobra é menos óbvio: cada bug no escalonador se multiplica pelo número de combinações. Uma cópia rasa da configuração, que num treino único não faz diferença, numa grade de cinquenta faz cinquenta execuções mexerem no mesmo objeto. Foi aí que a grade me custou mais noites do que me poupou, e volto a isso adiante.

Fine-tuning com rede de segurança, e previsão ao vivo que não improvisa

O fine-tuning faz backup do modelo original antes de tocar nele, compara as métricas de teste antes e depois, e recomenda reverter se alguma métrica crítica piorar mais que 5%. É pouco código e evita a situação clássica de "ajustei o modelo em dados novos e não sei mais se ficou melhor". A previsão ao vivo busca os dados da Binance, monta a janela exatamente como no treino, inclusive a política do canal do alvo lida do config salvo, e produz a previsão com a mesma inversão de normalização. Isso é obrigatório, não é zelo: um canal a mais ou a menos muda o formato da entrada e o modelo simplesmente não aceita. Um config salvo antes de uma chave existir é lido com o valor que o produziu, porque assumir o padrão novo quebraria a inferência de um modelo antigo.

Os problemas que a bancada pegou, e o que eles custaram

Aqui está a parte que justifica ter construído uma bancada em vez de um notebook com um modelo. Durante o desenvolvimento, antes de qualquer resultado ir para a dissertação, apareceram problemas de metodologia que nenhum teste unitário pega, porque os números continuam parecendo razoáveis. Todos foram encontrados e corrigidos a tempo. O preço não foi em resultado publicado. Foi em tempo: semanas de execuções da grade, em várias GPUs, que tiveram que ser descartadas e refeitas do zero com os padrões corrigidos, porque um número produzido pelo comportamento antigo não é comparável com um produzido pelo novo. Conto os três maiores, porque cada um virou uma peça do framework.

O número bonito que era persistência

Os primeiros resultados com normalização local tinham R² de 0,999 e um RMSE muito menor que o da normalização global. Era o resultado que a gente quer ver, e era o resultado que deveria ter acendido a luz amarela. O mecanismo, quando fui procurar, era exato: no modo de alvo relativo ao último valor, a rede prevê um número no espaço normalizado que depois é multiplicado pelo último preço observado. Prever zero nesse espaço é, identicamente, a persistência. Diferença máxima verificada em teste: 0,0. A rede não precisava aprender nada; bastava emitir uma constante. Não havia vazamento de índice no janelamento nem no scaler global; o efeito era real, só não era habilidade. Foi isso que fez o módulo de baselines e o diagnóstico de mimetismo virarem obrigatórios em todo treino. Depois dele, uma execução inteira de busca em grade foi para o lixo, porque nenhuma daquelas métricas dizia o que eu precisava saber.

Um canal a mais em um braço só

O segundo explicava por que a normalização local parecia tão melhor que a global: os dois braços não recebiam a mesma entrada. O braço local tinha o fechamento das 96 barras anteriores como canal extra, por necessidade da normalização, e o global não. E é exatamente esse canal que torna a persistência trivialmente expressável. A diferença medida misturava o efeito da normalização com o efeito de ter o fechamento como feature. A correção foi uma chave de três valores, include_target_channel: equalized, que inclui o canal em todas as estratégias e é o padrão; never, para isolar o efeito da normalização; e legacy, que mantém o comportamento antigo atrás de uma chave, para medir quanto ele distorcia. Com ela deu para medir em vez de discutir:

BraçoNormalizaçãoCanal do alvoO que mede
AGlobalNão (4 canais)O braço global como era
BGlobalSim (5 canais)Quanto da distância fecha só por dar o fechamento ao global
CLocalSim (5 canais)O braço local como era
DLocalNão (4 canais)O local sem a feature que facilita a persistência

Primeira medição, em BTCUSDT, uma semente, dez épocas: dar o fechamento ao braço global reduziu o erro dele em 34% (RMSE de 4.701 para 3.100). Tirá-lo do braço local piorou 17% (343 para 401). Com features iguais dos dois lados, a vantagem da normalização por janela permaneceu: 9,0 vezes com cinco canais e 11,7 vezes com quatro. Ou seja, o efeito é real e grande, e sem a matriz eu o teria superestimado sem saber quanto. A mesma varredura achou o fine-tuning normalizando o fechamento pela janela de abertura, por usar o índice zero como canal do alvo. Nenhum teste pegava, porque os números continuavam razoáveis. É a assinatura desse tipo de problema.

Quando LSTM e Transformer previam a mesma coisa

Havia um sintoma anotado e sem explicação: sob normalização multi-escala com horizonte de um passo, LSTM e Transformer produziam previsões idênticas. Duas arquiteturas diferentes dando o mesmo número não é robustez; é um problema a montante que torna a rede irrelevante. O mecanismo é aritmético. Com horizonte de um passo, a fatia do alvo é preenchida por replicação do único valor que existe, vira um vetor constante, e o desvio padrão é exatamente zero. O alvo que o backbone vê é identicamente zero, o regressor que prevê o desvio futuro é ajustado sobre zeros e devolve algo da ordem de 10⁻¹⁴, e na desnormalização a saída da rede é multiplicada por esse quase-zero e desaparece. A previsão vira a média predita pura. Nenhuma das duas redes estava sendo usada.

Medido com H = 1, L = 96, períodos [96, 48, 24]Comportamento antigoCorrigido
Desvio padrão do alvo do backbone1,3 × 10⁻⁶1,26
Desvio futuro predito (mediana)5,1 × 10⁻¹⁵2,19
Efeito de somar 1 à saída da rede1,0 × 10⁻⁸2,63
Desvio padrão do alvo real5,195,19

A correção se afasta do método original, e deixo isso explícito: Qin e colegas pressupõem horizonte maior ou igual ao período. Nas escalas em que o horizonte é menor, a política window_stats usa as estatísticas da última fatia da janela do alvo como referência. São causais, bem definidas e computáveis na inferência. O modo error levanta exceção, para que ninguém rode um regime inválido em silêncio. Com horizonte maior que todos os períodos, as políticas coincidem exatamente. Esse foi o problema que mais execuções levou consigo, porque toda a grade da normalização multi-escala com horizonte unitário era inválida e teve que ser refeita.

O trade-off que a correção cria

Sob horizonte unitário com todas as escalas degeneradas, a EvoMSN e a EvoMSN-like viram a mesma coisa, porque os preditores auxiliares, única diferença entre as duas, nunca são chamados. Isso está documentado e coberto por teste. A consequência honesta é que a comparação entre as duas variantes só faz sentido com horizonte multi-passo.

E os pequenos, que são os que passam

Reprodutibilidade como contrato, não como intenção

Cada correção acima mudou o comportamento padrão do código, e todas as execuções que foram para a dissertação saíram dos padrões corrigidos. Mesmo assim, carreguei o comportamento antigo atrás de uma chave em todas as correções: canal do alvo, embargo, política de horizonte curto, escala dos períodos. Por três motivos. Primeiro, para poder medir o efeito de cada correção lado a lado, como na matriz acima, em vez de confiar na minha explicação. Segundo, para que configs e modelos salvos antes de uma chave existir continuem carregáveis, com o valor que os produziu. Terceiro, para que as execuções antigas, descartadas, continuem reproduzíveis se alguém quiser entender por que foram descartadas. Deixo documentada a configuração exata do modo antigo e as cinco sementes da seção de repetição, com o aviso de que os padrões atuais produzem números diferentes, porque os padrões são as versões corrigidas.

O custo, dito de frente

Carregar caminhos antigos é complexidade permanente. Cada chave de três valores é um ramo a mais para testar e um lugar a mais para errar. Paguei esse custo porque a alternativa era um repositório em que ninguém consegue verificar por que um número mudou. A regra é que o modo antigo existe para comparar e para carregar o que já foi salvo, nunca para rodar coisa nova, e a mensagem de aviso aparece toda vez que ele é ativado.

Isso é sustentado por 34 testes e por integração contínua que roda lint e testes sem precisar de banco. Dois deles protegem decisões em vez de código: um garante que a classe antiga de grid, que ignorava o tamanho da janela e maximizava o erro quando a métrica não se chamava "loss", não volte; outro garante que a fábrica de trainers importe módulos por importlib e nunca por string executada.

O que ainda falta

O que eu faria desde o primeiro dia

Escreveria o módulo de baselines antes do primeiro modelo, e a matriz de confundimento antes da primeira comparação entre normalizações. Os dois custam uma tarde. O que eles teriam evitado custou semanas de GPU: execuções inteiras da grade descartadas e refeitas. A bancada acabou pagando por si mesma justamente porque pegou tudo isso antes de qualquer número sair. Tê-la completa antes dos experimentos teria sido mais barato ainda.

Resumo da conversa