Cabeçalho do motor de decisão Aurora com os selos do modelo e a confirmação de integridade

Fase 6 do CRISP-DM: implantação. É a que quase todo portfólio pula, porque é onde o projeto para de ser gráfico e vira ferramenta — e onde aparecem perguntas que nenhuma métrica de validação responde. Este texto é o que precisou existir para o modelo de risco da Aurora sair do notebook, e o que aprendi ao colocá-lo online de verdade.

🚀

Está no ar: o motor de decisão roda aqui no site, dentro do seu navegador. Preencha uma proposta, veja a probabilidade, o custo dos dois lados e a explicação SHAP daquele cliente. A segunda metade deste artigo conta como um modelo de gradient boosting foi parar dentro de uma página estática.

Conferência de integridade antes de deixar alguém pontuar cliente

A primeira coisa que o app faz não é mostrar tela. É prever um cliente fixo e comparar o resultado com o valor gravado no treino, com tolerância de 1e-9:

# o cliente de referência tem que devolver sempre o mesmo número
pipeline = joblib.load(CAMINHO_MODELO)
obtido = pipeline.predict_proba(pd.DataFrame([CLIENTE_REFERENCIA]))[:, 1][0]
integro = abs(obtido - PROBABILIDADE_REFERENCIA) < 1e-9

O motivo é específico e desagradável: uma versão diferente do LightGBM carrega o mesmo arquivo .joblib e prevê valores diferentes — em silêncio. Sem exceção, sem aviso, sem nada quebrar. O app continua funcionando e as decisões continuam saindo; só param de ser as decisões que você validou.

A conferência transforma isso em erro visível. Se o número não bater, a tela mostra o esperado, o obtido, e st.stop() — ninguém pontua cliente nenhum.

💡

Esse teste custa uma linha e uma constante. É o tipo de proteção que só parece exagero até o dia em que alguém atualiza uma dependência num ambiente de produção e o modelo passa a decidir outra coisa sem que ninguém perceba.

A política dentro da tela, não num PDF

Painel que explica os custos do erro, a derivação do limiar e a importância global das features
O custo dos dois erros, a conta do limiar e a importância global por SHAP — tudo a um clique de quem vai usar a decisão.

O analista não precisa acreditar no corte de 9,09%: ele confere de onde saiu. Custo do falso negativo (R$ 5.000), do falso positivo (R$ 500), a razão de 10 para 1 e a derivação:

p* = custo_FP / (custo_FP + custo_FN)
p* = 500 / (500 + 5.000) = 0,0909

Colocar a política na tela muda a conversa. Quando alguém discorda da taxa de negativa, a discussão vai direto para os custos — que são premissa de negócio, discutível — em vez de virar desconfiança genérica do modelo.

Dez campos, dezesseis features, e dois checkboxes que não são detalhe

Formulário de proposta com os dez campos informados pelo analista
O analista informa 10 campos. As outras 6 features saem do mesmo código que preparou o treino.

O formulário pede dez valores; o pipeline deriva as outras seis sozinho. E dois checkboxes que parecem acessório carregam informação real, apurada na exploração da base:

MarcaçãoO que significaInadimplência do grupo
Renda não informadao campo fica vazio e vira a flag renda_ausente5,61% contra 6,95%
Sem histórico no birôgrava o código 98 nas três colunas de atraso54,65% contra 6,60%

Quem não informa renda inadimple menos — o contrário do que a intuição sugere. E quem chega com o código do birô inadimple 54,65%, oito vezes a taxa do resto da carteira. Campo em branco não é buraco a tapar com a mediana e seguir a vida: é sinal. Tratar as duas situações como ausência de dado jogaria fora as duas informações.

A decisão com preço dos dois lados

Veredito de negativa com probabilidade, limiar e custo esperado de aprovar e de negar
Cliente com três atrasos de 90+ e 92% do limite usado: 69,10% de probabilidade. R$ 3.455 de custo esperado se aprovar, R$ 154 se negar.

Mostrar só a probabilidade seria mostrar metade. O que decide é a comparação: R$ 3.455 se aprovar contra R$ 154 se negar. Do outro lado da carteira, um cliente de 0,87% inverte a conta — R$ 43 se aprovar contra R$ 496 se negar.

Escritos lado a lado, os dois números tornam o limiar óbvio sem precisar explicá-lo: o corte é exatamente onde as duas colunas se cruzam. É a mesma conta da parte 11 da série, agora por cliente em vez de por carteira.

SHAP daquele cliente, não do modelo em geral

Cinco fatores que mais pesaram na decisão, com valores SHAP e a frase correspondente
Cada barra vira frase. O valor SHAP fica exposto embaixo, para quem quiser auditar.

Importância global responde "o que o modelo olha". O comitê de crédito pergunta outra coisa: "por que este cliente". São perguntas diferentes e exigem contas diferentes — a primeira é uma média sobre a base, a segunda é a decomposição de uma predição.

Cada barra vira uma frase em português — "uso elevado do limite rotativo (92% do limite disponível)" — com o número exposto logo abaixo. A frase serve para a conversa; o número, para a auditoria. Esconder um dos dois empobrece o outro.

A carta de negativa sai pronta

Texto de comunicação ao cliente gerado a partir dos três fatores de maior peso
Os três fatores de maior peso, em português, mais a oferta explícita de revisão humana.

Essa é a parte que fecha o argumento da explicabilidade. Não basta o time de dados entender o modelo: alguém precisa dizer a um cliente — e eventualmente a um regulador — por que aquele crédito foi negado, em linguagem que não exige saber o que é log-odds.

O texto sai com os três fatores de maior peso, declara que a decisão veio de um modelo estatístico auditável e oferece revisão humana. Explicabilidade não é gráfico bonito para a apresentação interna; é isto aqui.

Base inteira, com o contrato de entrada na tela

Aba de envio de arquivo com o contrato de entrada e a validação do arquivo enviado
As 10 colunas obrigatórias, as faixas aceitas e as regras que evitam erro de leitura — antes de o arquivo ser pontuado.

A aba de arquivo valida antes de pontuar, e o que ela procura veio direto dos problemas reais da base: achou 96 ou 98 nas colunas de atraso, avisa que são códigos de status do birô e não contagem; aceita renda em branco sem reclamar; ignora coluna extra, inclusive o alvo se vier junto.

Resultado da base inteira com decisão e principal motivo por linha
Cada linha com sua decisão e o motivo que mais pesou, e o CSV pronto para a esteira.

Na saída, cada linha volta com decisão e principal motivo. A coluna do motivo é o que permite a alguém revisar uma fila de negativas sem abrir o modelo.

A parte que não aparece na tela

O main.py não faz conta. Toda regra está no servico.py, que importa a mesma biblioteca que treinou o modelo. E o app consome um único artefato: o pipeline inteiro serializado, com os parâmetros de preparação dentro dele.

# os parâmetros aprendidos no treino viajam dentro do objeto
pipeline.named_steps["preparo"].parametros_

Existe um parametros_preparacao.json no projeto, e o app não o lê. Ler o JSON por fora abriria a porta para o app usar um conjunto de estatísticas diferente do que o modelo viu no treino — a mediana de renda de uma extração, o modelo de outra. É por isso que a preparação nunca diverge entre treino e produção: não existem duas versões do código, nem duas cópias das constantes.

O problema que o Streamlit não resolvia

Com o app funcionando, apareceu a questão que este artigo não tinha previsto: demorava para abrir.

Não é defeito do Streamlit. É o que ele é: um servidor Python que precisa estar de pé. No primeiro acesso depois de um tempo parado, alguém tem que acordar o contêiner e importar LightGBM, scikit-learn, SHAP, pandas e matplotlib antes de desenhar o primeiro pixel. Para uma ferramenta interna, tudo bem. Para um link num portfólio, onde o visitante decide em segundos se vale esperar, não.

A saída foi mudar o lugar onde o modelo roda. O pipeline treinado virou um artefato JSON — as 600 árvores achatadas em arrays, mais as duas medianas aprendidas no treino — e a página ganhou três funções escritas em JavaScript puro, sem biblioteca nenhuma:

EtapaO que fazEquivalente em Python
preparaçãoderiva as 6 features e aplica as regras de tratamentoaurora/preparacao.py
prediçãopercorre as 600 árvores e soma as folhasLGBMClassifier.predict_proba
TreeSHAPdecompõe a predição, exatoshap.TreeExplainer

A terceira é a que dá trabalho. TreeSHAP não é uma aproximação por amostragem: é o algoritmo de Lundberg que percorre cada árvore mantendo o conjunto de caminhos possíveis, com as operações de estender e desfazer que tornam o cálculo exato viável em tempo polinomial. Portá-lo significava reimplementar essa mecânica inteira — e ter como provar que estava certa.

Como eu sei que a porta funcionou

Aqui a conferência de integridade do app Python virou também a régua da portabilidade. O mesmo cliente de referência tem que devolver o mesmo número:

// no navegador, sem nenhuma biblioteca
AuroraModelo.prever(AuroraModelo.preparar(CLIENTE_REFERENCIA, artefato), artefato)
// → 0.1569673418089306
// esperado pelo pipeline em Python:
// → 0.1569673418089306

Um caso não prova nada, então sorteei 400 clientes — incluindo os caminhos chatos: renda ausente, dependentes ausentes, sentinela do birô, razão dívida vazia — e comparei tudo contra o pipeline original:

O que foi comparadoMaior diferença em 400 casos
Matriz de 16 features depois da preparação0 (idêntica)
Probabilidade de inadimplência1,1 × 10⁻¹⁶
Valores SHAP das 16 features1,3 × 10⁻¹⁵
Aditividade (base + Σ SHAP = log-odds)1,6 × 10⁻¹⁴

Essas diferenças são o limite da precisão de ponto flutuante de 64 bits, não do método — é a mesma ordem de grandeza que você obtém somando os mesmos números em ordem diferente. Vale registrar um detalhe que ajudou: antes de portar, conferi que o shap.TreeExplainer e o pred_contrib nativo do LightGBM concordavam exatamente entre si. Dois caminhos independentes chegando ao mesmo resultado deram a certeza de que havia um alvo fixo para mirar.

O que mudou na prática

≈ 40 ms
para baixar e destrinchar as 600 árvores
0,017 ms
por cliente na probabilidade — 2.000 linhas em 76 ms
≈ 6 ms
por cliente com TreeSHAP completo

Sem servidor, sem contêiner para acordar, sem fila. E um efeito colateral que acabou virando argumento de venda: os dados que o visitante digita não saem do dispositivo dele. Numa ferramenta que pede renda, dívida e histórico de atraso, isso deixa de ser detalhe técnico.

A diferença de custo entre as duas contas — 0,017 ms contra 6 ms — também mudou o desenho da tela. Numa base enviada por arquivo, a probabilidade é calculada para todas as linhas de imediato, e o TreeSHAP só roda nas que alguém vai de fato olhar. Na hora de exportar o CSV com o motivo de cada linha, o cálculo vai em blocos, devolvendo o controle ao navegador entre um e outro, para a aba não congelar.

⚖️

O que esta versão não faz: o app Python continua sendo o caminho certo quando o modelo precisa ficar privado, quando a base é grande demais para o navegador, ou quando a decisão tem que ser registrada num sistema. Publicar as árvores num JSON é publicar o modelo — aqui isso é desejável, porque o objetivo é justamente que qualquer pessoa possa inspecioná-lo. Num produto real, raramente seria.

O ciclo fechado

Negócio → Dados → Preparação → Modelagem → Avaliação → Implantação. A sexta fase é a que transforma um número de ROC AUC em algo que alguém consegue usar, discordar e auditar. E foi só ao colocá-la de pé que apareceram as três perguntas que o resto do projeto não tinha feito: o modelo que carregou é o mesmo que eu validei? quem vai usar entende de onde vem o corte? e o cliente que recebeu "não" consegue saber por quê?

Nenhuma delas é respondida por uma métrica de validação. Todas são respondidas por uma tela.

🎛️

Teste você mesmo: o motor de decisão está no ar, com perfis prontos para clicar — inclusive um cliente que fica a um centésimo de ponto percentual do limiar e é negado por causa disso.

📊

Fase 6 da série Aurora — um modelo de risco de crédito do zero ao deploy. O mapa completo reúne as doze partes; veja também a documentação técnica, o epílogo sobre o desempate entre modelos e o código no GitHub ↗.