Forecasting · Séries temporais · Dados abertos do ONS

Prever a carga elétrica do Brasil, de 1h a 24h à frente, e deixar o modelo provar em público

Um pipeline completo de previsão da carga do Sistema Interligado Nacional: features sem vazamento, validação cruzada temporal, um canário de ruído que poda features inúteis, baselines que precisam ser batidos e um holdout intocado de 2026. E a parte viva: toda segunda-feira o modelo registra suas previsões em público, antes de o realizado ser publicado.

vs baseline forte61,5%de redução de erro vs o baseline sazonal-semanal
média dos 24 horizontes no holdout de 2026
1,72%MAPE do modelo no holdout intocado
103.380 previsões avaliadas, 1h a 24h à frente
82,8%cobertura do intervalo P10-P90 (nominal: 80%)
quantis honestos, não só previsão pontual

48.144 horas de carga do SIN (jan/2021 a jun/2026, dados abertos do ONS) · holdout: todas as origens de 2026, jamais vistas em nenhuma escolha · LightGBM global multi-horizonte com quantis P10/P50/P90 · ciclo semanal gratuito via GitHub Actions · todos os números desta página saem dos artefatos de src/evaluate.py.

01 · O problema

Cada horizonte é um problema diferente

O operador do sistema elétrico precisa saber a carga da próxima hora (despacho fino) e a de amanhã cedo (planejamento de geração). São perguntas com físicas diferentes: em 1h, a inércia da série domina; em 24h, quem manda é o calendário. Um bom sistema de previsão precisa ser honesto sobre como o erro cresce com o horizonte, e é exatamente isso que este projeto mede.

⚡ Por que carga elétrica?

É a série temporal aplicada por excelência: sazonalidade tripla (dia, semana, ano), feriados que mudam tudo, e um custo real para cada MW errado, para cima (desperdício) ou para baixo (risco operacional).

🎯 Multi-horizonte de verdade

Um único modelo global recebe o horizonte como feature e prevê de 1h a 24h. As métricas são reportadas por horizonte: a média esconderia que prever 1h é fácil e 24h é outro esporte.

📏 Intervalo, não só ponto

Operação decide com margem de segurança. O modelo produz P10/P50/P90 e a página mede se o intervalo cumpre o prometido (cobertura nominal de 80%) fora da amostra.

A matéria-prima: quatro semanas de carga do SIN

Médias de 3h, nas últimas quatro semanas do período avaliado. O ciclo diário (vale de madrugada, pico à tarde) e o degrau dos fins de semana saltam aos olhos.

6070809008/0615/0622/0629/06

Eixo vertical em GW (milhares de MW). É essa regularidade que torna o baseline sazonal-semanal tão competitivo, e é por isso que ele, e não a persistência, é a régua que o modelo precisa bater.

02 · Dados e qualidade

Pré-processamento auditável, não faxina silenciosa

A série vem do portal de dados abertos do ONS (parquet anual, somando os 4 subsistemas por hora). Cada decisão de limpeza é contada e vai para um relatório versionado: a regra é nunca "consertar" dado em silêncio.

48.144
horas contínuas (jan/2021 a jun/2026), grade horária completa reconstruída e verificada
0
lacunas na janela: as defesas (interpolação só em gaps ≤3h, resto vira NaN reportado) existem e foram testadas, mas este recorte veio íntegro
4→1
subsistemas somados por hora; horas com subsistema faltante seriam anuladas, não somadas pela metade
tipo≠tipo
drift de esquema real: em anos antigos a carga vem como texto; a coerção é explícita e as falhas, contabilizadas
Outlier de dado ≠ outlier de realidade. O pipeline sinaliza desvios extremos contra o perfil típico de cada (dia da semana, hora) usando estatística robusta (mediana e MAD), mas não remove nada automaticamente: um apagão é realidade operacional que o modelo precisa conhecer, não erro de medição para varrer para baixo do tapete. Na janela 2021+ nenhum ponto ultrapassou o limiar de sinalização.
Por que a janela começa em 2021? 2020 é um regime quebrado (COVID derrubou a carga e mudou o padrão semanal). Treinar com aquele ano ensinaria ao modelo um Brasil que não existe mais. Recorte de dados também é decisão de modelagem, e fica documentada.
03 · Engenharia de features

34 features, um contrato anti-vazamento e um canário

Cada linha do dataset é um par (origem t, horizonte h). O contrato: features da série usam só o passado da origem (shifts ≥ 0); do instante-alvo, entra apenas calendário, que é conhecido com antecedência infinita. Quebrar essa regra infla o backtest e quebra em produção.

GrupoFeaturesPapel
Autorregressivas (origem)lags 1-6h, 24h, 48h, 168h, 336h e carga na origeminércia de curto prazo e memória semanal
Janelas (origem)média/mín/máx/desvio 24h, média 168h, variações 1h/24h/168hnível e tendência recentes
Referências do alvocarga na mesma hora ontem e há 7 dias (shifts 24-h e 168-h, sempre ≥ 0)o "sazonal ingênuo" embutido como feature
Calendário do alvohora, dia da semana, mês, seno/cosseno de hora e dia do ano, fim de semanaas três sazonalidades
Feriadosé feriado, véspera, pós-feriado + nome do feriadoquedas de 10-20% que o calendário comum não vê
Estruturahorizonte hum modelo global para os 24 horizontes
Controleruido_aleatorio ~ N(0,1)o canário: régua de corte de importância
Alta cardinalidade sob controle: o nome do feriado importa (Carnaval não se comporta como Natal), mas cada feriado raro virar categoria própria é convite ao overfitting. Os 8 nomes mais frequentes ganham categoria; o resto vira outros. Junto com nao_feriado, a coluna fecha com 10 categorias.

O canário de ruído em ação

Importância por ganho no modelo tunado, com o ruído incluído de propósito. A linha tracejada vermelha é a régua: feature que informa menos que ruído puro não merece o posto.

0,0%10,0%20,0%30,0%40,0%participação no ganho total do modelo (%)ref_semana_mesma_hora46,7%ref_ontem_mesma_hora17,7%var_168h6,9%dow_alvo5,9%hora_alvo2,5%var_24h2,5%doy_sin2,3%media_168h2,2%doy_cos2,2%hora_cos2,1%max_24h1,4%min_24h1,4%hora_sin0,8%media_24h0,8%nome_feriado0,7%ruido_aleatorio0,0%

Veredito desta rodada: o ruído terminou com ganho exatamente zero (nunca foi escolhido em split algum) e nenhuma das 34 features ficou abaixo dele, então o corte removeu apenas o próprio canário. O modelo enxuto foi re-validado nos mesmos folds: MAE de 1.394,4 MW (todas) contra 1.394,7 MW (enxuto). Empate técnico, como esperado quando não há o que podar: a régua está no pipeline e agirá quando features fracas aparecerem (e a regularização da config vencedora já segura o resto).

04 · Protocolo

Validação temporal, tunagem e um holdout que ninguém toca

A ordem importa: primeiro o holdout é trancado, depois a validação cruzada escolhe os hiperparâmetros, e só no fim o modelo enfrenta 2026. Nenhum número desta página veio de dado que participou de alguma escolha.

🔒 Holdout intocado

Todas as origens de 2026 (jan a jun) ficam fora de tudo: 103.380 pares (origem, horizonte) que o modelo só viu uma única vez, na avaliação final.

📆 CV temporal expansiva

4 folds com validação trimestral em 2025 e treino usando apenas o passado de cada fold. Embaralhar linhas em séries temporais é vazamento: o modelo "veria o futuro" dos vizinhos.

🎲 Busca aleatória com semente

20 configurações de LightGBM sorteadas (folhas, taxa de aprendizado, amostragem, regularização L2), early stopping por fold, seleção por MAE médio. Reproduzível de ponta a ponta.

Configuração vencedora: 63 folhas, taxa de aprendizado 0,03, mínimo de 200 amostras por folha, 75% das features e 70% das linhas por árvore, sem regularização L2. Nada exótico, e é essa a lição: em dados tabulares/temporais bem preparados, a diferença vem das features e do protocolo, não de hiperparâmetros mágicos.
05 · Resultados no holdout

O erro cresce com o horizonte. A vantagem sobre o baseline também.

Seis meses de 2026 que o modelo nunca viu: 103.380 previsões avaliadas. A comparação justa é contra o sazonal-semanal, o baseline que qualquer operador usaria de graça.

MAE por horizonte: modelo vs baselines

Erro absoluto médio em MW para cada horizonte de 1h a 24h, no holdout de 2026.

modelo (P50)sazonal-semanalpersistência
02.5005.0007.50010.00014812162024horizonte da previsão (horas à frente)persistênciasazonal (7 dias)modelo (P50)

Leituras: a persistência morre rápido (em 12h está 11.440 MW de erro: prever "igual a agora" não atravessa o ciclo diário); o sazonal é estável (~3.567 MW) mas ignora o estado atual do sistema; o modelo combina os dois mundos e fica entre 836 MW (1h) e 1.588 MW (24h), com skill de 76,5% no primeiro horizonte e 55,6% no último.

Uma previsão de verdade, vista de perto

Origem real do holdout: as últimas 48h observadas, o leque P10-P90 das 24h seguintes, o realizado e o que o baseline sazonal teria dito.

60708090-48h-24horigem+6h+12h+18h+24hhoras em relação à origem da previsãointervalo P10-P90últimas 48h observadassazonalrealizadoP50

Eixo em GW. O leque alarga com o horizonte, como deve: incerteza honesta cresce com a distância. Bastidor importante: os quantis brutos do boosting saíram estreitos (cobertura de 72,3% no holdout, contra 80% prometidos). Em vez de aceitar ou alargar no olho, o intervalo passa por calibração conformal (CQR, Romano et al. 2019) por horizonte, aprendida em nov-dez/2025, fatia que o ajuste nunca viu. Resultado no holdout de 2026: 82,8% de cobertura, entre 80,0% e 85,7% em todos os 24 horizontes.

Média 1h-24h no holdoutModelo (P50)Sazonal-semanalPersistência
MAE1.373 MW3.567 MW8.815 MW
MAPE1,72%4,5%11,1%
Redução de erro vs sazonal61,5%referência-147%
06 · O modelo ao vivo

Backtest bom é ótimo. Track record público é outra categoria.

Toda segunda-feira, um workflow gratuito do GitHub Actions baixa o dado mais novo do ONS, gera a previsão das próximas 24h e a registra neste repositório, antes de o realizado ser publicado (a divulgação do ONS tem ~1 semana de defasagem). Quando o realizado chega, o confronto entra no placar abaixo. Sem servidor, sem custo, sem como trapacear: o carimbo do commit é a prova.

Placar ao vivo

Carregando o track record...

Previsto (P50) vs realizado, com a banda P10-P90, para as previsões semanais já confrontadas com o dado oficial. Fonte: previsoes.json · track_record.json (públicos e versionados).

1 · Segunda, 12:00 UTC

O Actions roda src/score_live.py: baixa o parquet do ano corrente, reconstrói a série com o MESMO pipeline do treino e prevê as 24h após o último instante publicado.

2 · Registro imutável

As previsões entram em docs/live/previsoes.json num commit assinado pelo bot. Histórico completo, com data de geração, disponível para auditoria de qualquer pessoa.

3 · Reconciliação

Na semana seguinte, o realizado publicado pelo ONS é confrontado com o que foi previsto: MAE, MAPE e cobertura do intervalo atualizam o placar automaticamente.

07 · Lições

O que este projeto ensina

Seis princípios que valem para qualquer forecasting sério, não só para carga elétrica.

  1. Baseline forte primeiro, modelo depois.

    O sazonal-semanal custa zero e erra ~4,5%. Todo MW de melhoria reportado aqui é medido contra ele, não contra um espantalho. Modelo que não bate baseline honesto é complexidade sem causa.

  2. Multi-horizonte se reporta por horizonte.

    O MAE médio esconderia que 1h à frente é inércia e 24h é calendário. A curva de erro por horizonte é o retrato honesto do que o modelo sabe e de onde ele degrada.

  3. O canário de ruído mantém o modelo honesto.

    Uma feature N(0,1) define a régua: o que informa menos que ruído puro sai. É barato, objetivo e evita o acúmulo de features de estimação que só adicionam variância.

  4. Vazamento se previne por contrato, não por sorte.

    Toda feature declara sua disponibilidade: passado da origem (shift ≥ 0) ou calendário do alvo. Validação temporal expansiva e holdout trancado fecham as outras portas.

  5. Quantil é compromisso verificável (e calibrável).

    Os quantis brutos cobriam 72,3% quando prometiam 80%. A calibração conformal por horizonte corrigiu para 82,8% no holdout, com um conjunto de calibração que o ajuste nunca viu. Prometer intervalo, medir cobertura e calibrar quando falha: isso separa previsão probabilística de enfeite.

  6. "Produção" cabe num cron gratuito.

    Agendamento, scoring, registro imutável e monitoramento de erro real: o ciclo completo de um modelo em operação, implementado com GitHub Actions + Pages a custo zero. MLOps é disciplina, não fatura de cloud.

Reproduza em casa: python src/data.py → python src/train.py → python src/evaluate.py → python src/build_charts.py --splice. O ciclo vivo é python src/score_live.py, o mesmo que o Actions executa toda segunda. Evolução natural documentada no repositório: clima como exógena (exigiria previsão meteorológica no score) e horizontes de 25-48h.