research.jflneto.dev.br
Método

Como o experimento funciona

Taxonomia, pipeline e protocolo, explicados do zero — sem perder o rigor do Capítulo 5

02/09/2026Métodopágina de métodoorigem: config.yaml + pipeline/config.py + pipeline/m10_ingestao.py…m70_relatorios.py + PLANO_FECHAMENTO_2026-09.md + tools/analise_controle_etapa.py + tools/analise_roteador.py (run e1-20260804-175333)

Esta página explica a mecânica do experimento: como escolas de rede pública viram observações, como as observações viram duas representações de série, como cinco métodos rodam sobre elas, e como o resultado disso vira uma decisão sobre quantos livros comprar. Não substitui o guia de execução (parâmetros e ordem dos experimentos) nem o plano de experimentos (a especificação da rodada de Maceió) — os dois continuam sendo a referência normativa. Aqui o objetivo é outro: mostrar por que cada peça existe, com o menor jargão possível.

13etapas na cadeia de progressão
2 (pré-escola) → 27 (3ª EM)
3transições críticas
pré→1ºEF · 5º→6ºEF · 9ºEF→1ºEM
8módulos no pipeline
m10 a m70, mais m40b (ARIMA)
T−2gap do backtest
fiel à defasagem real do PNLD

1. A taxonomia: duas perguntas sobre a mesma série

Toda série de matrícula neste trabalho responde a duas perguntas independentes. A primeira é de onde vem o histórico que alimenta a predição: da própria etapa-alvo, ano a ano (retroativa), ou da trajetória que a coorte deveria ter percorrido para chegar até ali, encadeando etapas anteriores (escalonada). A segunda é onde a etapa-alvo está na cadeia: internamente, sem mudança estrutural esperada na origem do aluno, ou numa fronteira — o destino de uma das três transições em que o aluno tipicamente troca de escola, de rede ou de segmento.

As duas classificações vêm de lugares diferentes do código. Retroativa/escalonada é uma propriedade da série — pipeline/m30_series.py gera as duas para cada (escola, etapa-alvo, ano-alvo). Interna/fronteira é uma propriedade da etapa-alvo — calculada uma vez em pipeline/m10_ingestao.py::construir_mapa_transicoes e herdada por toda predição daquela etapa, nunca recalculada por escola.

RetroativaEscalonada
Etapa-alvo fixa; observa a mesma etapa, na mesma escola, em anos anteriores. Observa a etapa anterior na cadeia a cada ano de defasagem — uma etapa diferente por ano, a trajetória que a própria coorte percorreu.
Elegível onde a escola já ofertou a etapa-alvo antes, pelos anos que bastarem. Inelegível por construção onde a cadeia não tem passos suficientes antes do alvo — não é falta de dado (ver exemplo abaixo).

As 13 etapas e sua classificação

CódigoEtapaPapel na cadeiaClassificação do alvo
2Pré-escolaorigem — só entra como históriconunca é etapa-alvo
141º EFalvofronteira recebe Pré → 1º EF
152º EFalvointerna
163º EFalvointerna
174º EFalvointerna
185º EFalvointerna
196º EFalvofronteira recebe 5º → 6º EF
207º EFalvointerna
218º EFalvointerna
419º EFalvointerna — não confundir com 22
251ª EMalvofronteira recebe 9º EF → 1ª EM
262ª EMalvointerna
273ª EMalvointerna

Fonte: pipeline/config.py (ETAPA_NOME, TRANSICOES_CRITICAS) e config.yaml (cadeia_progressao, escopo.etapas_alvo). Classificação interna/fronteira calculada em pipeline/m10_ingestao.py::construir_mapa_transicoes.

A armadilha: 9º ano é a etapa 41, não 22

O código de etapa do INEP para o 9º ano do Fundamental é 41. O código 22 existe no Censo, mas é "EF Multi" — uma etapa residual, fora do escopo de educação básica regular deste trabalho (não listada em config.yaml: escopo.etapas_alvo). Confundir os dois quebra silenciosamente a cadeia entre o 8º EF e a 1ª EM. Por isso pipeline/m10_ingestao.py registra em relatorio_e0.md se o código 22 aparece nos dados de origem — um sanity check pensado justamente para pegar esse erro antes que ele se propague pelo resto do pipeline.

Exemplo: de onde cada representação puxa o histórico

Ilustrativo, não uma escola real

O site publica só dados agregados — nenhuma predição ou série é atribuída a uma escola identificável. O gráfico abaixo usa uma escola fictícia, com matrículas geradas por fórmula (não é um caso real). O que não é fictício é a mecânica: os pontos que aparecem em cada alvo são exatamente o que pipeline/m30_series.py::construir_series_retroativa/escalonada produz para essa tabela — mesma cadeia de progressão e mesmo gap T−2 de config.yaml, código de produção sem alteração (gerador: tools/figuras_como_funciona.py).

Como ler. escolha um alvo nos botões acima do gráfico. Pontos azuis (retroativa) sempre reaparecem na mesma etapa, em anos diferentes; pontos vermelhos (escalonada) são uma etapa diferente a cada ano — a trajetória que a coorte deveria ter percorrido. A faixa vermelha à direita é o gap T−2: nenhum ano ali entra no treino.

AlvoTransiçãoAnos de histórico — retroativa Defasagens possíveis — escalonadaEtapas que a escalonada alcança
1º EF (14)Pré → 1º EF150 nenhuma — inelegível por construção
6º EF (19)5º → 6º EF155 4º, 3º, 2º, 1º EF e Pré-escola — pula o 5º EF
1ª EM (25)9º EF → 1ª EM159 8º ao 1º EF e Pré-escola — pula o 9º EF

Contagens do exemplo ilustrativo acima (histórico contínuo 2007–2023, sem falha de oferta — o caso mais favorável possível à escalonada). Numa escola real, com séries incompletas, os números de defasagem tendem a ser menores, nunca maiores.

Por que a escalonada nunca vê o degrau imediatamente anterior ao alvo

k, a defasagem em anos, começa em protocolo.gap_minimo (2), não em 1 — a mesma regra antivazamento que limita a retroativa. Como cada ano de defasagem também é um passo na cadeia, a etapa mais recente que a escalonada consegue observar já é a segunda anterior ao alvo, nunca a primeira. É uma consequência direta e simétrica do gap T−2 — a mesma regra vale para as duas representações, não é uma limitação isolada da escalonada.

2. Como um experimento acontece: m10 → m70

Um run do pipeline é sete módulos numerados (mais um oitavo, opcional, para o ARIMA), cada um lendo config.yaml e nada mais, cada um gravando um artefato nomeado que o próximo módulo lê. Nenhum parâmetro é lido de fora do config; nenhum módulo reexecuta o que o anterior já fez.

Fluxo do pipeline: módulos 10 a 70 em sequência

Como ler. os sete módulos numéricos, em ordem — a mesma figura que pipeline/m70_relatorios.py::_fig_fluxograma gera para o relatório de cada run (reaproveitada aqui, não redesenhada). O guia de execução §2 detalha os artefatos A–F que cada módulo produz; abaixo, o que cada módulo faz e por quê, direto da docstring de cada arquivo.

Extraídas no build, sem Sphinx — "documentação de código barata", como o autor pediu: build.py::docstrings_pipeline_html lê a árvore sintática de cada pipeline/m*.py com ast.parse — sem importar o módulo, sem puxar pandas ou numpy só para gerar o site — e renderiza a docstring de módulo tal como está no código. Clique para abrir cada uma:

m10_ingestao.py — Módulo 10 — Ingestão (guia §3.1, plano Fase 1)

Entrada:

  • dados.matriculas_raw (config.yaml) — brasil_2007_2025.csv, grão escola x etapa x ano (nu_ano_censo, sg_uf, co_municipio, co_entidade, co_tp_localizacao, co_etapa_ensino, qtd_alunos). Fonte única para matrícula; NÃO usar os CSVs brutos anuais do INEP (não têm granularidade por etapa — só totais por bloco AI/AF/EM).
  • dados.escola_dependencia — snapshot único 2025 (decisão E0 registrada: dependência por ano é inviável, ver config.yaml e relatório de E0).
  • escopo.etapas_alvo / escopo.etapa_origem_extra (config.yaml) — restringe à educação básica regular (infantil, fundamental completo, médio completo); exclui EJA, técnico/FIC, normal/magistério, não seriado.

Saída:

  • data/canonico/observacoes.parquet (arquivo A do guia §2): join de dependência, filtro rede pública {1,2,3}, filtro de escopo de etapas.
  • data/mapa_transicoes_etapas.csv (arquivo B): cadeia de progressão copiada de cadeia_progressao (config.yaml) — armadilha: 9º ano é a etapa 41, não 22.
  • runs/<run_id>/relatorio_e0.md: sanity checks (E0, guia §8) + entrada correspondente em registry/registry_experimentos.yml.

Testes (tests/test_m10_ingestao.py): schema de A, unicidade escola-etapa-ano, cadeia de B sem o código 22.

Fonte: pipeline/m10_ingestao.py, docstring de módulo (extraída no build, sem Sphinx).

m20_caracterizacao.py — Módulo 20 — Caracterização e persistência (guia §3.2, plano Fase 2, experimento E5)

Entrada: data/canonico/observacoes.parquet (A) + data/mapa_transicoes_etapas.csv (B).

Saída: descritivas da base — estabilidade ano-a-ano por etapa/posição, persistência de oferta de etapas por escola, distribuição de tamanho de série. Alimenta a investigação de por que "repetir o último valor" é um baseline forte (SP3, exigência da banca — CONTEXTO.md §15.2/§15.3) e a Tabela T1 do guia §9.

A comparação ano-a-ano exclui deliberadamente o par (2017, 2018): E0 (runs/e0-20260729-112118/relatorio_e0.md) documentou um salto de 3,22x na granularidade da base bruta entre esses dois anos, não uma mudança real de matrícula — incluí-lo contaminaria a leitura de estabilidade com um artefato da base, não um sinal real.

Fonte: pipeline/m20_caracterizacao.py, docstring de módulo (extraída no build, sem Sphinx).

m30_series.py — Módulo 30 — Séries: retroativa e escalonada (guia §3.3/§4, plano Fase 3/4)

Entrada: data/canonico/observacoes.parquet (A).

Saída: data/canonico/series_modelagem.parquet (arquivo C) — formato longo (serie_id, representacao, co_entidade, etapa_alvo, ano_alvo, lag_k, ano_obs, etapa_obs, valor, n_hist, elegivel), com duas representações:

  • retroativa: etapa_obs == etapa_alvo, mesma escola, anos anteriores.
  • escalonada: para o alvo e no ano y, usa a etapa anterior na cadeia em y-k (mesma escola) — a trajetória esperada da coorte. k começa em protocolo.gap_minimo (não em 1), para respeitar o mesmo gap T-2 da retroativa. Como a cadeia (config.yaml: cadeia_progressao) já liga pré-escola -> ... -> EF -> EM inteira, um alvo de Ensino Médio encadeia sozinho até o Fundamental — satisfaz a reconstrução pedida em E-EM (guia §8) sem código especial.

serie_id é hash md5 determinístico de (co_entidade, etapa_alvo, ano_alvo, representacao) — estável entre runs, calculado uma vez por série (não por linha) para não pagar hash em cada observação histórica.

Regras (guia §4/§6), válidas para as duas representações:

  • etapa_alvo restrito a escopo.etapas_alvo (pré-escola é só origem, nunca etapa-alvo).
  • Anos-alvo = protocolo.anos_alvo_backtest (backtesting de origem móvel).
  • Zeros são ausência de oferta, não quantidade — descartados do histórico antes de contar n_hist (não geram linha em C).
  • elegivel = n_hist >= elegibilidade.min_anos_treino.

Cobertura estrutural da escalonada: etapas logo após a pré-escola (14, 15, 16, 17) têm, no máximo, 0 a 3 lags possíveis dentro da cadeia com gap T-2 — sempre abaixo do mínimo de 4 anos, portanto **inelegíveis por construção**, não por falta de dados. É um achado (mesma natureza da cobertura baixa do 1º EM esperada em E-EM), não um bug — ver relatório de E2/E4.

Elegibilidade não implica alvo observável: a escalonada gera uma linha em C sempre que a *cadeia de origem* existe (mesma escola, etapas/anos anteriores), independentemente de a escola ofertar a etapa_alvo no ano_alvo — elegivel só depende de n_hist sobre o histórico, nunca do valor em ano_alvo. Por isso n_elegivel (histórico suficiente) e n_avaliavel (elegível + y_true não-nulo no ano_alvo, calculado a partir de observacoes.parquet em m40_modelos) divergem muito mais na escalonada do que na retroativa — a escalonada fica "elegível" para etapas que a escola já não oferta mais (ex.: EF completo sem 1ª série de EM), enquanto a retroativa só é elegível para etapas que a própria escola já ofertou nos mesmos anos usados como histórico. As métricas em si nunca ficam contaminadas por isso (m50_avaliacao.avaliar filtra y_true.notna()); o que precisa ser lido com cuidado é a tabela de cobertura, que deve reportar as duas contagens separadamente (ver cobertura_por_representacao).

Teste antivazamento obrigatório (guia §6): max(ano_obs) <= ano_alvo - 2 em C.

Fonte: pipeline/m30_series.py, docstring de módulo (extraída no build, sem Sphinx).

m40_modelos.py — Módulo 40 — Modelos (guia §3.4/§5, plano Fase 3)

Entrada: data/canonico/series_modelagem.parquet (C, representação retroativa).

Saída: runs/<run_id>/predicoes.parquet (arquivo D, append-only por run) — os 5 métodos P0 (média, último valor, regressão linear, suavização exponencial, Kalman) sobre séries elegíveis (n_hist >= min_anos_treino).

Vetorização (guia §3): média, último valor e regressão linear são calculados via agregação pandas (groupby + forma fechada de mínimos quadrados para reglin) — sem laço Python por série. Suavização exponencial e Kalman são recursivos por natureza e rodam em um único laço por série (o "caso série-a-série" citado no guia); os dois juntos, para não pagar o overhead de agrupamento duas vezes.

Fallback (guia §4): séries com histórico parcial (1 a min_anos_treino-1 anos) só recebem "último valor" (fallback_usado=True); análises comparativas devem usar o subconjunto sem fallback (fallback_usado == False).

elegivel é recomputado aqui a partir de n_hist e de config["elegibilidade"]["min_anos_treino"] — nunca lido da coluna elegivel gravada por m30_series.py. m30 grava todas as séries com n_hist já contado e só marca elegivel com o min_anos_treino do config.yaml vigente no momento em que rodou; se este módulo confiasse nessa flag, rodar um run de sensibilidade de min_anos_treino (ex.: config_min2) exigiria re-rodar o m30 inteiro (caro: ~808 MB, a etapa mais pesada do pipeline) só para trocar um limiar. Como C já carrega n_hist por série, recomputar elegivel = n_hist >= min_anos_treino aqui é O(1) por série e respeita a disciplina do projeto ("config é a única fonte") — qualquer outro módulo que precisar de elegibilidade deve fazer o mesmo, nunca confiar em uma flag congelada por outro módulo em outro momento.

y_true vem diretamente de observacoes.parquet no ano_alvo — pode ser nulo se a escola não ofertou a etapa naquele ano (a predição ainda é escrita, mas erro/erro_abs ficam nulos e m50_avaliacao descarta essas linhas).

Fonte: pipeline/m40_modelos.py, docstring de módulo (extraída no build, sem Sphinx).

m40b_arima.py — Módulo 40b — ARIMA (item 2 da auditoria), extensão de m40_modelos

Não substitui m40_modelos.py nem seu predicoes.parquet: escreve um arquivo irmão, runs/<run_id>/predicoes_arima.parquet, com **exatamente o mesmo schema** de D (pipeline.m40_modelos.COLUNAS_D, metodo="arima"). m50 e m60 concatenam todos os predicoes*.parquet do diretório do run, então o ARIMA entra nas tabelas e testes do pipeline sem reescrever o arquivo grande já publicado e sem perder rastreabilidade (guia: "config é a única fonte", aplicado aqui a "run é a única fonte" — nunca sobrescrever D).

Mudança de escopo (registrada aqui, não só em pipeline/modelos/arima.py, porque muda a arquitetura deste módulo): a primeira versão rodava statsmodels série a série (Python puro), tinha que amostrar porque não cabia em ~2h. A versão atual ajusta IMA(1,1) sem constante por soma de quadrados condicional (CSS) **vetorizada em numpy sobre todas as séries de uma vez** (pipeline.modelos.arima.ajustar_lote) — não há mais laço Python por série no caminho de produção, então a amostragem estratificada saiu: este módulo roda a base elegível inteira do run canônico. Só o run canônico (e1-20260729-122616) roda ARIMA por enquanto — decisão do autor; o run min=2 fica para depois, o código já suporta (basta apontar --run-id/--config), só não foi executado ainda.

Entrada: data/canonico/series_modelagem.parquet (C) + observacoes.parquet (A, para y_true) — os mesmos arquivos de m40_modelos.py. elegivel é recomputado a partir de n_hist e config["elegibilidade"]["min_anos_treino"], pela mesma razão e do mesmo jeito que em m40_modelos.gerar_predicoes.

Construção da matriz (construir_matriz): filtra C para linhas elegíveis (vetorizado — n_hist já está em cada linha de C, não precisa de groupby para decidir elegibilidade), ordena por (serie_id, ano_obs), calcula a posição de cada observação dentro da série (groupby.cumcount) e a primeira diferença (groupby.diff), e espalha os dois numa matriz densa (n_series, T) via indexação numpy — sem laço Python por série. T = maior n_hist-1 da base (<=16, já que o maior n_hist observado é 17). Para o run canônico isso é uma matriz de ~4,8M x 16 (~614 MB em float64) — cabe folgado nos 19 GB de RAM da máquina.

Processamento em blocos (modelos.arima.execucao.tamanho_bloco_series, config.yaml): pipeline.modelos.arima.ajustar_lote nunca materializa uma matriz grade × N (mantém só o melhor (soma_quadrados, theta) corrente por série), então o pico de memória por chamada é O(N), não O(grade × N) — a matriz densa acima já é o maior consumidor. Ainda assim, processar em blocos de séries é a margem de segurança pedida: cada bloco aloca só O(tamanho_bloco) nos arrays intermediários da varredura de θ, em vez de O(n_series inteiro), o que importa se a base crescer além do run canônico atual.

ARIMA só é ajustado sobre séries elegíveis (mesmo critério de suav_exp e kalman em m40_modelos) e com histórico suficiente para identificar θ (n_hist >= 3 — ver docstring de pipeline/modelos/arima.py); no run canônico (min_anos_treino=4) toda série elegível já satisfaz isso, então não há filtro adicional. Séries onde o ajuste falha (não deveria acontecer para n_hist>=3, mas a função pode devolver NaN em casos degenerados) recebem sucesso=False, motivo_falha="arima_historico_insuficiente".

Além de predicoes_arima.parquet (schema D), este módulo grava runs/<run_id>/theta_arima.parquet (serie_id, theta, eps_ultimo, alpha_equivalente = 1+theta) — não faz parte do schema D (que não tem campo para hiperparâmetro por série), mas é o que sustenta a leitura "o α estimado por série diverge do α=0,5 fixo do pipeline" pedida no relatório; mantém rastreabilidade sem violar o schema exigido de D.

Modo fatia (--fatia/--fundir, adicionado na virada do canônico para min2, 01/09/2026): a base min2 (~7,37 M séries elegíveis, ~53% mais que o min4) não cabe numa chamada só do ambiente onde este módulo passou a rodar depois da virada — shell de 3,9 GB sem swap, cada chamada morre em ~180s, processo em background não sobrevive entre chamadas (min4 levou 223s inteiro, com folga de 19 GB de RAM na máquina do autor; aqui não há nem a RAM nem os 180s). A base é fatiada por (representação, ano_alvo) — 10 fatias fixas, cruzando as 2 representações de C com os 5 anos de protocolo.anos_alvo_backtest (config.yaml); não é um parâmetro novo em config.yaml porque não é uma decisão de modelagem — é só o jeito de caber o processamento em várias chamadas, refazer a granularidade não muda nenhum número. --fatia i lê só essa fatia de C via DuckDB (carregar_c_fatia, não pd.read_parquet(CAMINHO_C) — C tem 37,9 M linhas com serie_id string, carregar inteiro não cabe nos 3,9 GB), roda construir_matriz/ajustar_em_blocos só sobre ela, e grava um parcial em runs/<run_id>/_arima_fatias/ (fora do padrão predicoes*.parquet do run — não pode ser lido por engano por m50/m60 antes da fusão). --fundir concatena os 10 parciais nos arquivos finais (predicoes_arima.parquet, theta_arima.parquet), soma os tempos reais medidos por fatia (não extrapola) e escreve o relatório e o registry — mesmo formato que executar() (base inteira, uma chamada só) sempre escreveu, então quem lê o relatório não vê diferença entre os dois modos. executar() continua existindo sem mudança — é o caminho certo numa máquina com RAM/tempo de sobra (ex.: a do autor); o modo fatia é aditivo.

Fonte: pipeline/m40b_arima.py, docstring de módulo (extraída no build, sem Sphinx).

m50_avaliacao.py — Módulo 50 — Avaliação (guia §3.5/§7, plano Fase 3)

Entrada: runs/<run_id>/predicoes*.parquet (D) — todos os arquivos que casam esse padrão no diretório do run são lidos e concatenados (_carregar_predicoes), não só predicoes.parquet. Isso existe para o ARIMA (item 2 da auditoria, pipeline/m40b_arima.py): ele grava predicoes_arima.parquet à parte, com o mesmo schema D, em vez de reabrir e reescrever o predicoes.parquet grande — concatenar aqui é o que faz o ARIMA entrar nas métricas e comparações sem duplicar o arquivo publicado. Qualquer relatório que citar métricas do método "arima" deve deixar claro que o n dele é amostral (bem menor que os métodos P0, que rodam sobre a base inteira) — ver docstring de m40b_arima.py.

Saída: runs/<run_id>/metricas_agregadas.csv (arquivo E) — sempre derivado de D por este script, nunca calculado ad hoc em notebook. MAE + WAPE + bias médio/direção, sempre por etapa e por posição (nunca só global). segmento distingue duas leituras (guia §4/risco "cobertura nivelada"):

  • sem_fallback: só séries com histórico suficiente (fallback_usado=False) — comparação justa entre os 5 métodos P0. É o segmento usado para qualquer alegação comparativa.
  • com_fallback: todas as séries, incluindo as que só têm "último valor" por fallback — leitura operacional "predição para tudo".

Decomposição MAE = tamanho_médio × WAPE (guia §7) incluída no relatório, já que tamanho_médio = mean(y_true) e WAPE = sum(erro_abs)/sum(y_true) implicam tamanho_médio × WAPE = mean(erro_abs) = MAE exatamente, quando os dois usam o mesmo n.

Proibições do guia §7: MAPE em agregações, R² fora das comparações principais, "significativo" sem teste formal (isso é módulo 60), ranking global sem segmentação — por isso este relatório nunca reporta um único "campeão" sem qualificar por etapa/posição/segmento.

Fonte: pipeline/m50_avaliacao.py, docstring de módulo (extraída no build, sem Sphinx).

m60_estatistica.py — Módulo 60 — Estatística (guia §3.6/§7, plano Fase 5, experimento E6)

Entrada: runs/<run_id>/predicoes.parquet (D).

Saída: runs/<run_id>/testes_estatisticos.csv (+ .json) — protocolo unificado:

  • método × método (Wilcoxon pareado + rank-biserial), dentro de cada representação, nas mesmas unidades (co_entidade, etapa_alvo, ano_alvo).
  • retroativa × escalonada (Wilcoxon pareado + rank-biserial), por método, no subconjunto comum — formaliza estatisticamente o achado descritivo de E2/E4 (Fase 4).
  • interna × fronteira (Mann-Whitney + rank-biserial, grupos independentes), por método, dentro da retroativa.

Holm aplicado separadamente dentro de cada família de testes (não globalmente — famílias testam hipóteses diferentes, corrigi-las juntas seria conservador demais sem necessidade).

Nota sobre N grande: com milhões de pares, quase qualquer diferença é "estatisticamente significativa" (p ≈ 0) — o número que importa aqui é o tamanho de efeito (rank-biserial), não o p-valor isolado (guia §7: proíbe "significativo" sem qualificar; CONTEXTO.md §15.2 Prof. Gustavo pede tamanho de efeito ao lado do teste formal).

Critério de sucesso (guia §8, E6): zero teste estatístico "solto" fora deste módulo.

ARIMA (item 2 da auditoria, pipeline/m40b_arima.py — Fase 1.3a/1.4 do plano de fechamento, 02/09/2026): d/d_reduzido concatenam todos os predicoes*.parquet do run — inclui predicoes_arima.parquet quando existe.

Histórico: até 01/09/2026 o ARIMA rodava amostral e testar_metodo_x_metodo ficava restrito a METODOS_P0 de propósito (um dropna() sobre o pivô inteiro encolheria a comparação dos 5 P0 para o tamanho da amostra do ARIMA). Isso mudou nos dois lados ao mesmo tempo: o ARIMA passou a rodar sobre a base elegível INTEIRA (sem amostragem — pipeline/m40b_arima.py), e testar_metodo_x_metodo passou a incluir todos os métodos (5 P0 + ensemble_media/mediana + arima) com dropna POR PAR em vez de um dropna global — ver docstring da função para o porquê (um dropna global ainda encolheria os pares P0 x P0 pela cobertura menor do ARIMA, que exige n_hist>=3; por par, cada comparação usa só a interseção dos dois métodos dela). testar_representacao_x_representacao e testar_interna_x_fronteira nunca filtraram por método — o ARIMA já entrava neles automaticamente antes dessa mudança, só que amostral; agora entra na base inteira, mesmo N que os outros métodos na mesma unidade.

Fonte: pipeline/m60_estatistica.py, docstring de módulo (extraída no build, sem Sphinx).

m70_relatorios.py — Módulo 70 — Relatórios (guia §3.7/§9, plano Fase 6)

Entrada: runs/<run_id_e1>/ completo (D, E, testes_estatisticos) + runs/<run_id_e5>/caracterizacao/ (módulo 20) + data/mapa_transicoes_etapas.csv.

Saída:

  • Tabelas LaTeX T1-T6+T8 em runs/<run_id_e1>/tabelas/*.tex e figuras PNG F1,F2,F3,F5,F6 em runs/<run_id_e1>/figuras/*.png (guia §9).
  • **T7 (GPR/coorte) e F4 (razões de progressão) ficam pendentes** — E7 não foi implementado nesta fase (guia §8 permite adiar: "se não couber, fica Maceió + trabalho futuro"; GPR é um tipo de modelo novo, não reaproveita os 5 P0). Rastreado como trabalho futuro.
  • Relatório MD automático em runs/<run_id_e1>/relatorio_modulo70.md.
  • Subcomando export-site: dado um run_id, atualiza a figura Plotly em content/_data/<slug>/ (dado derivado, seguro de sobrescrever) e deixa um rascunho de texto em content/_rascunhos/, que o build.py ignora. Nunca escreve em caminho publicado: o texto final da página é revisado pelo autor — o exportador entrega dados + estrutura, não prosa. Substitui os scripts manuais tools/gen_fig_*.py usados nas Fases 1-5.

E9 (guia §8, "D + resíduos"): curva falta×sobra por quantil de compra, calculada aqui a partir dos resíduos do método "último valor" (retroativa, segmento sem_fallback) — leitura retrospectiva do trade-off de compra do PNLD, não uma nova predição (por isso não há risco de vazamento temporal: os quantis resumem erros já realizados no backtest, não alimentam nenhuma predição nova).

Carga de d (predicoes.parquet): executar() decide entre pd.read_parquet direto (runs pequenos, ex. fixtures de teste) e _carregar_d_reduzido (DuckDB, runs grandes) por tamanho de arquivo — ver _LIMIAR_DUCKDB_BYTES e a docstring de _carregar_d_reduzido para o motivo e o que exatamente é lido.

Fonte: pipeline/m70_relatorios.py, docstring de módulo (extraída no build, sem Sphinx).

3. O protocolo: backtest, elegibilidade, métricas e testes

Backtest de origem móvel, gap T−2

Cinco anos-alvo (protocolo.anos_alvo_backtest: 2021 a 2025), cada um com sua própria janela de treino expansiva, sempre restrita a ano_obs ≤ ano_alvo − gap_minimo. O gap_minimo vale 2 não por convenção estatística, mas porque é a defasagem real entre o dado do Censo Escolar que fica disponível e o ano em que o PNLD decide a compra: testar com dado que só existiria depois da decisão real não é backtesting válido para este problema, é um vazamento disfarçado de generalização. O guia de execução §6 tem o diagrama completo dos cinco folds; aqui vale o resumo: nenhum ano dentro do gap entra em nenhum ajuste — nem o α da suavização exponencial, nem as iterações do EM do Kalman.

Elegibilidade mínima
elegibilidade.min_anos_treino = 2 — decisão do autor de 01/09/2026: "a elegibilidade mínima passa a ser o mínimo possível". Sensibilidade em 3, 4 e 5 anos (sensibilidade_min_anos).
Zeros
Ausência de oferta da etapa, não quantidade zero de alunos — descartados do histórico antes de contar n_hist.
Rede
escopo.rede = [1, 2, 3] — federal, estadual, municipal. Escolas privadas nunca entram: fora do escopo do PNLD.

Duas métricas, nunca uma sem a outra

Toda predição gera um erro ŷ − y; as agregações abaixo (pipeline/m50_avaliacao.py) partem dele.

MétricaFórmulaPor quê
MAEmean(|ŷᵢ − yᵢ|) Erro na escala real — alunos, o que decide quantos livros pedir. Sozinho, favorece etapas com escolas grandes.
WAPEΣ|ŷᵢ − yᵢ| / Σyᵢ Erro como fração do volume — a única base válida para comparar etapas de porte muito diferente, como o 1º EF (turmas pequenas) e o Ensino Médio.

As duas nunca aparecem sozinhas: MAE = tamanho médio × WAPE é uma identidade exata quando os dois lados usam o mesmo n (pipeline/m50_avaliacao.py, docstring do módulo) — o que torna a decomposição uma tabela obrigatória nas transições, separando quanto do erro é porte da escola e quanto é penalidade proporcional real.

Os testes: por que dois, e por que Holm

TesteQuandoEfeitoPor quê
Wilcoxon pareado método × método; retroativa × escalonada — mesma unidade (escola, etapa-alvo, ano-alvo) nos dois lados r = (W₊ − W₋) / (W₊ + W₋) Compara duas medições da mesma unidade sem supor distribuição normal do erro, sem descartar o pareamento que um teste para grupos independentes jogaria fora.
Mann-Whitney interna × fronteira — grupos independentes (uma etapa é uma coisa ou outra, nunca as duas) r = 1 − 2U / (n₁·n₂) Mesma lógica não paramétrica, para quando não há pareamento possível: os dois grupos comparam etapas diferentes, não a mesma unidade sob duas condições.

As duas fórmulas de efeito acima devolvem rank-biserial, na mesma escala: < 0,1 desprezível 0,1–0,3 pequeno 0,3–0,5 médio ≥ 0,5 grande — a régua usada em todo o Capítulo 5 (ver, por exemplo, os números do run canônico).

Por que o p-valor não é a leitura, aqui

As comparações somam de centenas de milhares a milhões de pares. Com N nessa escala, o p-valor converge para < 0,001 em quase qualquer comparação — inclusive diferenças sem nenhuma relevância prática. É uma propriedade do tamanho da amostra, não do método: a partir de certo N, um p pequeno deixa de discriminar "existe efeito" de "existe dado". O número que carrega informação é o tamanho de efeito (rank-biserial) da tabela acima — por isso ele acompanha todo teste do pipeline (pipeline/m60_estatistica.py, coluna efeito_rank_biserial), nunca o p sozinho.

Por que correção de Holm, e por que não uma correção global

Cada família de teste (método × método; retroativa × escalonada; interna × fronteira) roda vários testes ao mesmo tempo — método × método, por exemplo, compara C(7,2) = 21 pares de métodos de uma vez. Sem correção, a chance de achar um "efeito" por puro acaso de múltiplas comparações cresce com o número de testes. A correção de Holm (step-down) controla isso teste a teste, sem a perda de poder de uma correção mais conservadora aplicada ao mesmo N. pipeline/m60_estatistica.py aplica Holm separadamente dentro de cada família, não globalmente — famílias testam hipóteses diferentes; corrigi-las juntas seria conservador demais, sem necessidade metodológica.

4. Controlar por etapa: por que um segmento pode parecer pior sem ser

Comparar escolas estaduais com municipais, ou o Centro-Oeste com o Nordeste, pelo MAE agregado dá uma resposta — mas essa resposta pode estar medindo a coisa errada. A razão é mecânica: a etapa-alvo é, disparadamente, a variável que mais move o erro neste trabalho (o Ensino Médio erra dezenas de alunos a mais que o Fundamental, só por causa do tamanho médio das turmas), e os níveis de um segmento quase nunca têm a mesma mistura de etapas. Uma rede que concentra Ensino Médio vai parecer pior que uma rede que não tem Ensino Médio nenhum — mesmo que, etapa a etapa, as duas se comportem exatamente igual.

É o que acontece com rede, no run canônico (min2): 43,1% dos pares avaliáveis da rede estadual caem no Ensino Médio (1ª a 3ª série), contra apenas 0,1% na rede municipal — no universo de pares de todas as redes juntas, o Ensino Médio é só ~11,8%. A rede estadual está, por construção do próprio sistema educacional brasileiro (não do pipeline), sobre-representada exatamente nas etapas onde qualquer escola erra mais com a representação escalonada. Comparar o MAE agregado de estadual contra municipal sem isso em mente é comparar duas populações de etapas diferentes e chamar a diferença de "efeito de rede".

O que significa "controlar por etapa"

Controlar por etapa significa comparar dentro do mesmo terreno: medir a diferença entre representações separadamente em cada uma das doze etapas — só assim a composição deixa de interferir, porque dentro de uma etapa todas as escolas comparadas estão na mesma etapa. O problema é como depois juntar essas doze medidas numa única leitura por segmento. A tentação óbvia — juntar usando o peso que cada etapa tem dentro do próprio nível sendo medido — não funciona: como esse peso é exatamente a composição que se quer neutralizar, a soma ponderada por ele devolve, por definição, o mesmo número agregado bruto de onde se partiu. Controlar por etapa exige um peso compartilhado: a mesma participação de cada etapa, não importa qual nível do segmento está sendo somado — assim, um nível concentrado numa etapa deixa de "puxar" o resultado para o que aquela etapa favorece, e todos os níveis são comparados como se tivessem a mesma mistura de etapas.

A versão anterior desta pergunta usava uma única etapa

A página qualificação × pipeline já fazia esta pergunta, mas controlando restringindo tudo a uma única etapa — o EF 5º ano, por ser a etapa com mais pares disponíveis (2,44 milhões, à frente de qualquer outra). Duas fraquezas: a escolha é arbitrária (nada além do tamanho da amostra justifica o 5º ano e não o 4º ou o 9º), e ela descarta a maior parte do subconjunto pareado de uma vez. Pior: o 5º ano não é neutro — é justamente uma etapa em que a escalonada tende a ganhar da retroativa: nas cinco regiões do país, sem exceção, o Δ MAE restrito só ao 5º ano é negativo (a escalonada com erro menor), mesmo nas regiões cujo Δ agregado bruto é claramente positivo (ver tabela abaixo). Controlar restringindo a essa etapa não é só perder poder estatístico: pode inverter o sinal do que se conclui, porque a etapa escolhida carrega seu próprio viés, não uma leitura neutra. O exemplo abaixo mostra isso acontecendo de fato.

EtapaParesΔ MAE (rede estadual)
EF 3º ano19.404+6,29
EF 4º ano245.432−2,87
EF 5º ano297.955−3,18
EF 6º ano227.807+3,75
EF 7º ano238.481+4,30
EF 8º ano253.242−0,86
EF 9º ano600.330−3,18
EM 1ª série464.731+13,86
EM 2ª série474.716+11,78
EM 3ª série486.122+8,99

Δ MAE = MAE(escalonada) − MAE(retroativa), pareado, rede estadual, todas as etapas com pelo menos 30 pares. Positivo favorece a retroativa.

Run e1-20260804-175333

Cálculo: comparação pareada retroativa × escalonada (mesma escola, etapa-alvo, ano-alvo, método; sem fallback; y_true não-nulo — protocolo de pipeline/m50_avaliacao.py::comparar_representacoes), agrupando os 8 métodos disponíveis no run (5 P0 + 2 ensembles + ARIMA, este último com cobertura menor por já entrar filtrado por sucesso), restrita a escolas da rede estadual, uma linha por etapa-alvo.

Fonte: tools/analise_controle_etapa.py → runs/e1-20260804-175333/analise_controle_etapa/por_estrato.csv

O sinal muda seis vezes ao longo da cadeia. Restringir tudo ao EF 5º ano isolado dá Δ = −3,18 — a escalonada parecendo francamente melhor na rede estadual. Olhar o MAE agregado bruto (que mistura a composição) dá o oposto: Δ = +4,42, a retroativa francamente melhor. Nenhuma das duas leituras usa a informação inteira. A leitura que soma as dez etapas com o mesmo peso que elas têm no total de pares de todas as redes — não o peso que têm dentro da rede estadual — dá Δ = +1,06: ainda a favor da retroativa, mas um quarto do valor bruto. A diferença entre +4,42 e +1,06 é o tanto do "efeito de rede estadual" que era, na verdade, a rede estadual carregando mais Ensino Médio que a média do país.

Como ler a figura

Run e1-20260804-175333

Cálculo: para cada segmento (rede, região, localização, porte em tercis) e nível, Δ bruto é o MAE pareado agregado sobre as dez etapas com cobertura escalonada; Δ agrupado estima o Δ dentro de cada etapa separadamente (mínimo de 30 pares por célula) e soma essas dez estimativas com o peso que cada etapa tem no universo do próprio tipo de segmento — o mesmo peso para todo nível daquele segmento, não o peso que a etapa tem dentro do nível sendo medido. Protocolo pareado idêntico ao de pipeline/m50_avaliacao.py::comparar_representacoes; efeito e teste de pipeline/m60_estatistica.py::_wilcoxon_pareado, reaproveitados sem reimplementação. 12.170.352 pares, 8 métodos (inclui ARIMA, cobertura menor).

Fonte: tools/analise_controle_etapa.py → runs/e1-20260804-175333/analise_controle_etapa/resumo.csv

Como ler. cada linha é um nível de segmento; o ponto cinza é o Δ bruto (a leitura ingênua, que mistura a composição de etapas de cada nível); o azul é o Δ agrupado (a mesma pergunta, respondida etapa por etapa e recombinada com peso compartilhado). A distância entre os dois pontos é quanto do efeito aparente era composição de etapa — quanto mais os dois pontos se aproximam, mais o "efeito do segmento" evapora ao ser medido do jeito certo. Um ponto que quase não se move é um segmento cujo efeito não depende de qual etapa ele concentra.

SegmentoNívelΔ brutoEfeito (bruto)Δ agrupadoEfeito (agrupado)Δ EF 5º ano isolado
PortePequeno−0,30+0,236 · pequeno−0,36+0,237 · pequeno−0,51
PorteMédio−0,30+0,164 · pequeno−0,30+0,157 · pequeno−0,96
LocalizaçãoRural+0,03+0,140 · pequeno+0,18+0,124 · pequeno−0,80
RedeMunicipal+0,45+0,094 · desprezível+1,43+0,046 · desprezível−1,58
RegiãoNordeste+1,17+0,055 · desprezível+2,16+0,032 · desprezível−1,01
RegiãoNorte+1,17+0,049 · desprezível+1,74+0,040 · desprezível−1,19
RegiãoSudeste+1,30+0,067 · desprezível+0,63+0,115 · pequeno−3,49
RegiãoSul+2,00+0,025 · desprezível+1,39+0,076 · desprezível−1,75
PorteGrande+2,74−0,017 · desprezível+2,16+0,025 · desprezível−2,71
LocalizaçãoUrbana+2,75−0,011 · desprezível+2,13+0,031 · desprezível−2,71
RegiãoCentro-Oeste+4,33−0,088 · desprezível+3,50−0,051 · desprezível−0,87
RedeEstadual+4,42−0,071 · desprezível+1,06+0,096 · desprezível−3,18
RedeFederal+4,77−0,210 · pequeno+4,66−0,214 · pequeno+0,48

Δ MAE em alunos, positivo favorece a retroativa. Efeito = rank-biserial (Wilcoxon pareado): <0,1 desprezível · 0,1–0,3 pequeno · 0,3–0,5 médio · ≥0,5 grande. A coluna "Δ EF 5º ano isolado" reproduz o método anterior (uma única etapa) para comparação direta — não entra no agrupamento.

A leitura dramática da figura antiga se confirma — mas não pelo caminho que ela mostrava

Rede, região e localização continuam sem sustentar um efeito de magnitude grande depois de controlados: todo Δ agrupado corresponde a um efeito rank-biserial desprezível ou pequeno, nunca médio ou grande — a mesma conclusão qualitativa da página de qualificação × pipeline. O que muda é o caminho até lá: a coluna "Δ EF 5º ano isolado" tem sinal oposto ao Δ agrupado em 10 dos 13 níveis testados — olhar só essa etapa não apenas perde poder, leva à conclusão contrária na maioria dos casos. Dois segmentos não se movem quase nada entre bruto e agrupado — porte (o mesmo achado já registrado na página de qualificação × pipeline) e rede federal, que concentra Ensino Médio quase tanto quanto a estadual (40,6% dos seus pares, contra 43,1% da estadual) mas não encolhe quando essa composição é neutralizada — evidência de que o efeito de federal não é a composição de etapas que ele carrega.

5. O roteador — ensemble a priori

Os cinco métodos e os dois ensembles do Capítulo 5 respondem a uma pergunta descritiva: "qual combinador funciona melhor, dado um pool fixo?" O roteador responde a uma pergunta diferente, operacional: para esta escola, nesta etapa-alvo, neste ano, qual representação — retroativa ou escalonada — e qual método usar, antes de gerar a predição, não depois de combinar várias. É por isso que ele é chamado de ensemble a priori: a escolha é condicionada à taxonomia da série (posição, etapa-alvo e, quando paga, volatilidade histórica e porte), estimada só em 2021-2023 e aplicada fora da amostra em 2024-2025 — nunca vê o ano que avalia.

Nenhuma rodada nova de modelo

As 8 predições por unidade (5 P0 + 2 ensembles + ARIMA) já existiam em predicoes*.parquet antes do roteador existir — ele só decide qual delas usar. Código: tools/analise_roteador.py; relatório completo: runs/e1-20260804-175333/relatorio_roteador.md.

A cascata de retaguarda

A escalonada não é elegível em toda etapa (o EF 1º ano, por construção — seção 1 acima), e o ARIMA exige n_hist≥3, contra n_hist≥2 dos outros 6 métodos do pool P0+ensembles. O roteador cobre isso com uma cascata em quatro degraus, não uma escolha única sem plano B: tenta o braço de menor erro de treino no estrato; se ele não existir nesta unidade específica, cai para o próximo melhor método na mesma representação; se a própria representação não existir para esta série, troca de representação; e, no limite, cai para ultimo_valor — o único método com cobertura a partir de n_hist≥1, por isso o único que quase sempre existe.

A cascata de retaguarda do roteador, com a cobertura medida em cada degrauA CASCATA DO ROTEADORrun e1-20260804-175333 · teste 2024-2025 · universo: toda unidade com y_true observado (n=1.170.156)unidade: escola × etapa-alvo × ano-alvoestrato treinado (2021-2023)posição × etapa-alvo × quartil de CV × tercil de porteDegrau 1o braço (representação, método) de MENOR MAE de treino no estratoelegíveis: os 8 métodos × 2 representações — inclui ARIMA quando n_hist≥372,7% das unidadesindisponível para esta unidade →Degrau 2próximo melhor método, NA MESMA representação do degrau 1dispara se o braço do degrau 1 não existe nesta unidade (ex.: ARIMA pedido, sériecom n_hist=2)15,1% das unidadesindisponível para esta unidade →Degrau 3melhor método treinado, NA OUTRA representaçãodispara se a série nem existe naquela representação (ex.: escalonada, EF 1º ano —inelegível por construção)3,9% das unidadesindisponível para esta unidade →Degrau 4 — último recursoultimo_valor direto: retroativa primeiro, senão escalonadadispara quando nem o estrato tinha regra treinada aplicável — único método comfallback a n_hist≥1, por isso quase sempre existe8,4% das unidadescobertura total: 100,0% das unidades — 0 sem predição

Como ler. cada degrau é tentado nesta ordem; a seta "indisponível para esta unidade" é o que dispara o próximo. A pílula colorida é a fração das 1.170.156 unidades de teste (2024-2025, qualquer representação) que parou naquele degrau — 100% coberto, 72,7% já no degrau 1.

Run e1-20260804-175333 · gerado em 2026-09-02

Cálculo: a regra treinada em 2021-2023 (posição × etapa-alvo × quartil de CV histórico × tercil de porte defasado) aplicada unidade a unidade em 2024-2025, com a cascata de 4 degraus descrita no texto — tools/analise_roteador.py::aplicar_cascata. Universo: toda unidade (escola, etapa-alvo, ano-alvo) com y_true observado, qualquer representação — não o universo restrito da comparação da seção seguinte.

Fonte: tools/analise_roteador.py → runs/e1-20260804-175333/analise_roteador/cascata_cobertura.csv

A regra, como tabela

No estrato base (posição × etapa-alvo, antes do refinamento por CV/porte), a escolha primária (degrau 1) e o primeiro fallback (degrau 2) são estas — a leitura direta é que o ARIMA entra como primeira escolha em 4 das 12 etapas (sempre na escalonada, sempre onde n_hist≥3 é comum o bastante para o treino confiar nele):

EtapaPosiçãoDegrau 1 — representaçãoDegrau 1 — métodoMAE treinoDegrau 2 (mesma repr.)
EF 1º anofronteiraretroativasuav_exp7,70ensemble_media
EF 2º anointernaretroativasuav_exp7,66ensemble_media
EF 3º anointernaescalonadakalman4,42ensemble_mediana
EF 4º anointernaescalonadaarima4,62kalman
EF 5º anointernaescalonadaensemble_mediana5,62kalman
EF 6º anofronteiraescalonadaarima12,77ensemble_media
EF 7º anointernaescalonadaarima12,24ensemble_media
EF 8º anointernaescalonadaensemble_mediana7,75kalman
EM 1ª sériefronteiraretroativaensemble_mediana30,64ensemble_media
EM 2ª sérieinternaretroativasuav_exp29,31ensemble_media
EM 3ª sérieinternaescalonadasuav_exp23,75ensemble_media
EF 9º anointernaescalonadaarima9,57kalman

reglin e media nunca aparecem como degrau 1 — consistente com A_geral do relatório e8 (reglin é sempre o pior isolado). A versão em produção (V2) refina cada uma destas 12 linhas por quartil de CV histórico e tercil de porte — 150 subestratos, ilegível em prosa; a tabela fina completa está em runs/e1-20260804-175333/analise_roteador/regra_V2_mais_porte.csv.

Run e1-20260804-175333 · gerado em 2026-09-02

Cálculo: MAE de treino (2021-2023) de cada braço (representação, método), por estrato — tools/analise_roteador.py::treinar_arm_mae + montar_decisao. Braço só entra na regra com n_treino≥200.

Fonte: tools/analise_roteador.py → runs/e1-20260804-175333/analise_roteador/regra_V0_posicao_etapa.csv

As features que pagaram

Testadas incrementalmente sobre o mesmo universo comparável e os mesmos anos de teste — a disciplina do pedido ("comece simples e só acrescente feature que pague"). Números corrigidos em 04/09 — a versão publicada em 02/09 tinha vazamento no CV histórico; ver o alerta logo abaixo antes de citar qualquer MAE desta seção:

VersãoFeatures além de posição × etapaMAE testeGanho
V0nenhuma10,35—
V1 produção+ quartil de CV histórico10,08+0,27
V2+ tercil de porte (defasado 2 anos)10,15+0,20 total

CV histórico paga. Porte, na leitura corrigida, deixa de pagar em cima do CV para este split — 10,15 contra 10,08, pior, não melhor — por isso a marca de produção passa de V2 para V1. A diferença entre as duas (0,07 aluno) é pequena diante da dispersão do roteador entre folds (desvio-padrão 1,57, seção "origem móvel" abaixo): não é evidência forte de que porte não ajude em geral, só de que não paga de forma confiável neste split específico — o vazamento estava no CV, não no porte (o porte já juntava pelo censo de ano_alvo − 2, o gap do protocolo, desde a primeira versão; nenhuma mudança nele). A marca de série com buraco ficou de fora por decisão de desenho: ao contrário de CV/porte, ela é calculada separadamente para cada representação — usá-la para ESCOLHER a representação seria circular.

Run e1-20260804-175333 · números corrigidos em 2026-09-04 (a versão de 02/09 tinha vazamento — ver alerta abaixo)

Cálculo: MAE do roteador no universo comparável (retroativa, 7 métodos P0+ensembles presentes, n_hist≥2), teste 2024-2025, n=1.147.959, para cada conjunto de features, com cv_hist/cv_quartil recalculado só até 2021 (fim da janela de treino − gap_minimo), nunca tocando 2022 em diante — tools/analise_roteador.py::executar_fold (--modo cv --cv-etapa controle --cv-controle-variante seguro).

Fonte: tools/analise_roteador.py → runs/e1-20260804-175333/analise_roteador/cv/controle_vazamento_seguro_mesmo_split/ganho_por_feature.csv

Vazamento encontrado e corrigido — o número certo é 10,08, não 9,89

O cv_hist usado como feature acima era calculado sobre TODO o histórico de observacoes.parquet, 2007–2025 — incluindo os próprios anos de teste, 2024 e 2025, no denominador do desvio-padrão. Na prática, a estatística usada para decidir qual método aplicar a uma escola em 2024/2025 já continha a matrícula real daquele mesmo ano — vazamento do y para dentro do insumo de decisão. O erro foi encontrado ao rodar a validação de origem móvel pedida para checar se o ganho se sustenta fora de um único corte (nenhuma rodada de modelo nova — é reavaliação sobre as predições que já existiam). Corrigido — CV recalculado só até o fim de cada janela de treino, nunca tocando o ano sendo testado —, o MESMO split publicado (treino 2021-2023, teste 2024-2025) dá 10,08, não 9,89. A conclusão qualitativa não muda: o roteador segue à frente dos 5 concorrentes (seção seguinte) e dos 4 folds da validação cruzada (mais abaixo) — mas o número publicado primeiro estava inflado, e é o corrigido que deve ser citado daqui em diante.

Run e1-20260804-175333 · checagem gerada em 2026-09-04

Cálculo: mesmo split (treino 2021-2023, teste 2024-2025) rodado duas vezes, tudo idêntico exceto a origem do CV — cv_hist do corte global 2007-2025 (vazado_original, reproduz o roteador publicado em 02/09) vs. cv_hist recalculado só até 2021 (seguro_mesmo_split) — tools/analise_roteador.py::executar_controle_vazamento. Os 5 concorrentes e os 2 oráculos ficam idênticos (bit-a-bit) entre as duas variantes, porque nenhum deles usa cv_hist — só o roteador muda.

Fonte: tools/analise_roteador.py → runs/e1-20260804-175333/analise_roteador/cv/controle_vazamento_delta.csv e relatorio_roteador_cv.md

Contra os concorrentes

No mesmo universo comparável (retroativa, os 7 métodos do pool presentes, n=1.147.959, 2024-2025) — os 5 concorrentes usam sempre a retroativa (a leitura padrão sem roteador: cobertura quase universal e já comprovadamente melhor que "preferir escalonada quando existir", relatório e8, B3); o roteador é livre para escolher a escalonada quando o treino disser que compensa:

CombinadorMAE
roteador (V1)10,08
oráculo geral (teto do espaço de ação do roteador)3,87
melhor método isolado (suav_exp, sem estratificar)10,40
seletor por estrato (o que já existe)10,43
ensemble média10,43
ensemble mediana10,49
último valor11,28

O roteador ganha dos 5 — inclusive do seletor por estrato, o melhor combinador do Capítulo 5 (10,45 no run original; 10,43 nesta leitura retroativa-apenas comparável). Efeito pequeno mas robusto: Wilcoxon pareado + rank-biserial + Holm (protocolo de pipeline/m60_estatistica.py), rank-biserial entre −0,13 e −0,21 nas 5 comparações (escala: <0,1 desprezível · 0,1–0,3 pequeno · 0,3–0,5 médio · ≥0,5 grande), p<0,001 em todas depois de Holm, n pareado entre 799 mil e 1,13 milhão. "Ganha, pouco" é a leitura honesta — não um salto qualitativo. Com o número corrigido, o roteador fecha 5,3% do gap até o teto do seu próprio espaço de ação (seletor → oráculo geral) e 16,2% partindo do último valor (eram 8,2% e 18,8% na leitura vazada — o vazamento também inflava a fração do gap fechado, não só o MAE). Não é o mesmo gap de 4,05 do relatório e8 — aquele foi medido com as duas representações como observações separadas, este é o teto de um espaço de ação maior (inclui a escalonada e o ARIMA). Estes números valem para ESTE split; a validação de origem móvel logo abaixo mostra que a margem varia por ano.

Run e1-20260804-175333 · números corrigidos em 2026-09-04 (a publicação de 02/09 usava CV vazado — ver alerta na seção anterior)

Cálculo: universo comparável = retroativa, os 7 métodos de pipeline.universos.CAMADA_P0_ENSEMBLES presentes (n_hist≥2) — pipeline/universos.py::camada_para_comparacao. Teste pareado nas mesmas unidades dos dois lados; cv_hist do roteador cortado em 2021 (fim do treino − gap_minimo), nunca vê 2024/2025.

Fonte: tools/analise_roteador.py → runs/e1-20260804-175333/analise_roteador/cv/controle_vazamento_seguro_mesmo_split/comparacao_mae.csv e comparacao_teste_estatistico.csv

Onde o roteador perde

Em 4 dos 12 estratos (posição × etapa), o roteador (V1, versão corrigida) tem MAE pior que o seletor por estrato — em 3 deles a diferença é pequena a moderada (0,01 a 1,37 aluno: EF 2º ano, EF 6º ano, EF 7º ano). O caso que pesa continua sendo a EM 3ª série: +6,18 alunos contra o seletor (pior que os +5,16 da leitura anterior, com vazamento). Mesma explicação de antes: nessa etapa, a estratificação por CV deixa alguns subestratos com n_treino baixo — a regra super-ajusta onde o treino é mais escasso. Para EM 3ª série especificamente, o seletor por estrato simples é a escolha mais segura, não o roteador fino.

Run e1-20260804-175333 · números corrigidos em 2026-09-04

Cálculo: MAE por estrato (posição × etapa-alvo) do roteador (versão V1, CV fold-segura) contra o seletor por estrato, mesmo split treino 2021-2023/teste 2024-2025 — tools/analise_roteador.py::onde_perde.

Fonte: tools/analise_roteador.py → runs/e1-20260804-175333/analise_roteador/cv/controle_vazamento_seguro_mesmo_split/onde_perde_por_estrato.csv

O ganho é estável? Validação de origem móvel, 4 folds

Um único corte treino/teste não separa "ganho real" de "par de anos favorável" — o n de teste é grande, mas n grande não substitui mais de um fold. Origem móvel expansiva, sem gap (treino termina no ano imediatamente anterior ao teste, mesma convenção do corte único acima): treino 2021 → teste 2022; treino 2021-22 → teste 2023; treino 2021-23 → teste 2024; treino 2021-24 → teste 2025. CV histórico recalculado em cada fold, cortado no fim da própria janela de treino — nunca vê o ano de teste daquele fold (a mesma correção de cima, repetida fold a fold).

Ano testeAnos treinoRoteadorMelhor isoladoSeletor estratoÚltimo valor
202220219,7110,8610,9011,51
20232021-202213,0713,3213,3914,98
20242021-202310,1010,4810,4911,44
20252021-202410,0310,3210,3611,12

O roteador vence os 5 concorrentes nos 4 folds, sempre com Holm significativo — o ganho é direcional e robusto. Mas o tamanho do efeito não é: cai de "pequeno" (2022, rank-biserial −0,23 contra o melhor isolado) para "desprezível" contra o melhor isolado em 2023 (−0,10) e contra 4 dos 5 concorrentes em 2025 (−0,08 a −0,10) — nunca inverte de sinal, mas se aproxima de zero. Dispersão do MAE do roteador entre os 4 folds: média 10,73, desvio-padrão 1,57 (CV 14,7%), mínimo 9,71 (2022), máximo 13,07 (2023 — pior fold). A leitura honesta é "ganha consistentemente, por pouco" — não "ganha por uma margem confortável e estável".

Run e1-20260804-175333 · gerado em 2026-09-04

Cálculo: validação cruzada de origem móvel, 4 folds (treino expansivo, sem gap), CV histórico fold-seguro em cada fold (cortado em max(anos_treino) − gap_minimo, nunca vê o ano de teste do próprio fold) — tools/analise_roteador.py --modo cv --cv-etapa fold. Protocolo estatístico de pipeline/m60_estatistica.py (Wilcoxon pareado + rank-biserial + Holm, dentro de cada fold).

Fonte: tools/analise_roteador.py → runs/e1-20260804-175333/analise_roteador/cv/resumo_cv_fold_a_fold.csv, dispersao_por_combinador.csv e relatorio_roteador_cv.md (relatório completo, fold a fold)

2023 é ano difícil, não falta de histórico

2023 é o pior fold — mas para os 8 combinadores testados igualmente (até o último valor isolado sobe de ~11,1-11,5 nos outros folds para 14,98 em 2023; o oráculo geral sobe de ~3,85-3,95 para 5,10). Contra a expectativa de que o fold com janela de treino mais curta fosse o pior, o fold de 2022 — treinado com só 1 ano de dados — teve o menor MAE do roteador e o maior efeito relativo sobre os concorrentes. Não há, nestes 4 folds, evidência de que a regra precise de mais de 1 ano de histórico de treino para funcionar: o que domina a variação entre folds parece ser o ano-alvo em si — 2023 difícil para todo mundo —, não a profundidade do treino. É uma leitura diferente de "precisa de mais histórico", e uma informação prática distinta para o PNLD: o risco a vigiar está em anos atípicos, não em regras jovens.

O teto também é mais baixo do que parece

A margem até o oráculo por unidade (3,87 a 5,89, conforme a leitura) não é toda alcançável. A concordância da escolha do oráculo entre anos consecutivos, para a mesma unidade, é fraca (kappa de Cohen = 0,105, contra as marginais reais de cada ano — não contra 1/5), e o oráculo defasado — a escolha que o oráculo faria no ano anterior, aplicada à predição do ano corrente, a única forma realizável de perseguir o oráculo — tem MAE 11,29 no subconjunto onde é aplicável (85,2% do universo de teste), pior que o próprio roteador no mesmo subconjunto (10,99). O roteador que já existe hoje já bate a régua honesta baseada em persistência ano a ano — não sobra margem alcançável por esse caminho: a distância até o oráculo perfeito é, em boa parte, sorte de amostra única, não um padrão que uma regra causal (treinada só com o passado) possa capturar. Detalhe completo em runs/e1-20260804-175333/relatorio_oraculo.md.

Run e1-20260804-175333 · gerado em 2026-09-04, por outro agente, em paralelo à validação de origem móvel

Cálculo: kappa de Cohen (contra as marginais reais de cada ano, não 1/5) sobre a escolha do oráculo por unidade entre pares de anos consecutivos, pooled 4 pares (2021→2025); oráculo defasado = escolha do oráculo no ano anterior aplicada à predição do ano corrente, no subconjunto onde aplicável — tools/analise_oraculo.py.

Fonte: tools/analise_oraculo.py → runs/e1-20260804-175333/analise_oraculo/estabilidade_kappa.csv, defasado_resumo_mae.csv e relatorio_oraculo.md

6. Trabalhos futuros: os cortes definitivos

Toda exclusão de escopo neste trabalho tem uma frase de justificativa — convenção do guia de execução, mantida aqui para o que foi cortado em definitivo em 01/09/2026, para não reabrir depois do congelamento.

CortePor quê
KMeans de regimes Identificar regimes de estabilidade por clustering seria uma pergunta descritiva adicional, fora do eixo único do Caminho E (transições como explicação da heterogeneidade). A pergunta de "por que o último valor é difícil de superar" já tem resposta sem introduzir um método novo: E5 (estabilidade e persistência).
Métodos Fuzzy Mudariam a família de modelos (os cinco P0 mais o ensemble) no meio do congelamento de experimentos, sem sinal de que resolveriam algo que a suavização exponencial e o ensemble por média já não resolvem.
GPR em escala nacional completa O efeito medido é de ~5% num único município (Maceió) — não sustenta transformar o método numa proposta nacional. O E7 testa a mesma direção numa amostra estratificada; não vira produção nacional.
Quantificação de incerteza / conformal prediction A pergunta de compra sob incerteza já tem resposta operacional pela curva falta×sobra por quantil empírico de resíduo (E9, ver run canônico). A garantia formal de cobertura do conformal fica como eixo à parte, não decidido para este trabalho.
PNLD × Censo Não existe dado público de quanto o PNLD efetivamente comprou por escola — não há como cruzar decisão real de compra com o Censo. A curva de compra/risco (E9) já é a melhor aproximação possível sem esse dado.
Alocação espacial Resultado negativo documentado: a formulação testada não resolveu as fronteiras. Reabre só numa versão futura recortada por rede de ensino — não como a formulação atual.
Cohort Survival como método próprio Absorvido pelo E7 amostral: o plano de experimentos já tratava "Cohort-survival / GPR" como a mesma ideia — razões de progressão grade-a-grade. Manter os dois nomes separados no texto final duplicaria um método, não descreveria dois.

Efeito Ceará: limitação declarada, não investigação nova

Sobrevive a todos os controles testados, sem mecanismo identificado

A diferença de MAE entre retroativa e escalonada no Ceará tem sinal oposto ao do resto do país e resiste a todos os controles testados (composição de etapas, porte, localização, rede) — ver qualificação × pipeline para os números completos. Nenhum mecanismo foi identificado. Por decisão do autor de 01/09/2026, o achado entra no texto final como limitação declarada, não como uma nova linha de investigação: o prazo de congelamento (15/09) não comporta abrir uma frente nova para explicar uma anomalia geográfica que os dados atuais não decidem.