Documentação técnica do projeto. A versão narrativa está na série de 12 artigos — comece pelo mapa da série. Para entender curva ROC e limiar na prática, há o explicador interativo. O código, os dados tratados e as saídas estão no repositório no GitHub ↗.
Prevendo inadimplência de 90+ dias em uma carteira de crédito não garantido
Documentação de um projeto conduzido pelas seis fases do CRISP-DM, do entendimento do negócio ao deploy. Cada decisão registrada aqui foi tomada a partir de um número medido na base — nenhuma a partir de boa prática.
- Base
- 150.000 × 11
- Alvo
- 90+ DPD / 24m
- Prevalência
- 6,684%
- Custo FN : FP
- 10 : 1
- Meta
- ROC AUC ≥ 0,85
Entendimento do negócio
o que a decisão precisa ser, antes de existir modelo
A Aurora é uma fintech de crédito pessoal não garantido para renda baixa e média. Cresceu em volume, mas a margem líquida oscila. A pergunta do comitê não é "quem é bom pagador" — é qual política de aprovação gera mais valor sem assumir risco incompatível com a estratégia.
Três objetivos em conflito: crescimento (manter volume de aprovações), risco (reduzir exposição) e rentabilidade (o árbitro entre os dois). O modelo não resolve o conflito — ele dá a linguagem comum para negociá-lo.
A saída do modelo é uma probabilidade, não uma classe
O modelo estima a probabilidade de inadimplência. A decisão de aprovar ou negar vem depois, aplicando um limiar econômico. Isso torna a calibração um requisito: um modelo que ranqueia bem mas cuja probabilidade não corresponde à realidade invalida qualquer limiar.
A assimetria de custo e o que ela implica
Um falso negativo (aprovar quem dá calote) custa 10× um falso positivo (negar quem pagaria). Duas consequências saem direto dessa razão:
- O limiar ótimo teórico é
p* = 1 / (1 + 10) = 9,09%, não 50%. - Acurácia está descartada como critério — ela pesa os dois erros igualmente.
Negar todos custa mais caro que aprovar todos: 0,9332 contra 0,6684 por cliente, na escala FN=10 / FP=1. Aritmética direta — o custo esperado de aprovar um cliente aleatório é 10 × 6,68% = 0,668; o de negá-lo é 1 × 93,32% = 0,933.
A assimetria 10:1 não autoriza uma política ultraconservadora. O valor do modelo não está em negar mais, está em negar os certos.
Baselines simulados
A base não tem coluna de decisão histórica, então o custo da política atual não é observável. Foram simulados quatro pontos de referência:
| Política | FN | FP | Custo/cliente | % negada |
|---|---|---|---|---|
| Aprovar todos | 10.026 | 0 | 0,6684 | 0,00% |
| Negar todos | 0 | 139.974 | 0,9332 | 100% |
| Negar se atrasos 90+ > 0 | 6.554 | 4.866 | 0,4694 | 5,56% |
| Negar se qualquer atraso > 0 | 3.264 | 23.601 | 0,3749 | 20,24% |
A última linha é a barra a bater: 0,3749 por cliente.
Entendimento dos dados
o arquivo se chama credito_tratado.csv e não está tratado
O arquivo não abre com leitura padrão
pandas.errors.ParserError: Error tokenizing data.
C error: Expected 1 fields in line 57579, saw 2
Separador é ;, quebra de linha CRLF. Corrigido isso, apareceu algo pior: duas
colunas contínuas chegam como texto.
Exportação em locale pt-BR gravou os valores ≥ 1 com ponto de milhar —
1.046.279.103 é na verdade 1,046279103. São 2.952 registros em
uso_limite_rotativo e 3.915 em razao_divida.
O reflexo natural diante de uma coluna-texto é pd.to_numeric(errors='coerce').
Isso apagaria em silêncio 2.950 clientes cuja taxa de inadimplência é 40,07% — 6,7× a
média da carteira. Sem mensagem de erro. O modelo perderia o subgrupo mais arriscado
da base e ninguém veria.
Um teto artificial em 2,0
Distribuição de razao_divida acima de 1: existem 34.779 registros entre 1 e 2,
zero entre 2 e 10, e 358 acima de 10. Distribuição contínua não tem buraco
assim. Alguém winsorizou em 2,0 ao produzir o arquivo, e os registros gravados como texto
escaparam do clip — 355 dos 358 valores acima do teto são exatamente eles.
Resultado: 31.215 clientes (20,81% da base) empilhados em
razao_divida == 2,0, com valor sintético.
Ausência de dado é sinal de risco menor
| Coluna | Nulos | % | Inadimpl. com dado | Inadimpl. sem dado | Razão |
|---|---|---|---|---|---|
| renda_mensal | 29.731 | 19,82% | 6,9486% | 5,6137% | 0,81× |
| dependentes | 3.924 | 2,62% | 6,7410% | 4,5617% | 0,68× |
Contraintuitivo e decisivo: quem não informou renda inadimple menos.
Imputar pela mediana sem sinalizar destruiria essa informação. A ausência também é
aninhada — todos os 3.924 sem dependentes estão dentro dos 29.731
sem renda_mensal, nenhum ao contrário. É falha estrutural de coleta, não acaso.
Descartar as linhas com nulo não é opção
Custaria 29.731 clientes (19,82%) e 1.669 inadimplentes — 16,65% de toda a
evidência de calote da base. E os descartados são um perfil específico: mediana de
idade 57 contra 51, razao_divida 6,76× maior. Além disso, em produção esse cliente
vai chegar: uma em cada cinco propostas não traz renda informada.
Outros problemas catalogados
- Sentinelas 96 e 98 nas colunas de atraso — 269 clientes, sempre nas três colunas ao mesmo tempo (prova de que é marcação de registro, não comportamento), com 54,65% de inadimplência contra 6,60% do restante.
- Renda máxima de R$ 3.008.750 contra p99 de R$ 25.000 — 120×. E 1.634 clientes com renda exatamente 0, que é ausência disfarçada.
- 1.573 linhas 100% duplicadas (1,05%). Sem
id_cliente, não há como saber se é a mesma pessoa. - Caudas impossíveis: um cliente com 54 financiamentos imobiliários (p99 = 4).
- Sem
id_clientee sem coluna temporal — impede split temporal, que seria o correto para um alvo de janela de 2 anos. Limitação declarada.
Preparação dos dados
uma função, chamada por todo mundo — inclusive pelo app
Split 75/25 estratificado no alvo. A assimetria foi preservada com 0,0018 ponto percentual de diferença entre treino (6,684444%) e teste (6,682667%).
Toda a preparação vive em uma função única, em módulo autocontido que o app Streamlit
importa direto. Passar parametros=None faz a função aprender; passar o dicionário
do treino faz apenas aplicar.
# os sinalizadores precisam ser criados antes da imputacao base["renda_ausente"] = base["renda_mensal"].isna().astype("int8") base["dependentes_ausente"] = base["dependentes"].isna().astype("int8") base["renda_mensal"] = base["renda_mensal"].fillna(parametros["mediana_renda"]) # as sentinelas aparecem sempre nas tres colunas ao mesmo tempo base["historico_atraso_sentinela"] = ( base[COLUNAS_ATRASOS].isin(parametros["valores_sentinela"]).any(axis=1).astype("int8")) for coluna in COLUNAS_ATRASOS: base[coluna] = base[coluna].clip(upper=parametros["limite_atrasos"]) base["renda_por_dependente"] = base["renda_mensal"] / (base["dependentes"] + 1) base["sobra_caixa"] = base["renda_mensal"] * (1 - base["razao_divida"])
Resultado: 112.500 × 17 no treino, zero nulos, zero linhas perdidas. Seis colunas novas.
As colunas novas carregam sinal?
| Coluna criada | Clientes = 1 | Inadimpl. grupo | Inadimpl. resto | Veredito |
|---|---|---|---|---|
| historico_atraso_sentinela | 202 | 53,4653% | 6,6003% | separa 8× |
| renda_ausente | 22.187 | 5,5393% | 6,9658% | sinal real |
| dependentes_ausente | 2.945 | 4,4482% | 6,7446% | sinal real |
| renda_no_teto | 225 | 6,2222% | 6,6854% | só rastreabilidade |
Das duas features contínuas, renda_por_dependente tem gradiente monotônico
limpo por quartil — 9,66% / 6,79% / 5,49% / 4,64%. Já sobra_caixa saiu
contaminada: 6,62% / 9,47% / 6,44% / 4,20%, com o quartil mais baixo tendo risco
menor que o do meio.
O problema de sobra_caixa é herdado. Como ela é
renda × (1 − razao_divida), os 20,81% de clientes empilhados no teto artificial
de razao_divida caem todos no mesmo ponto negativo. A feature não mede sobra de
caixa, mede o artefato do clip. Fica registrada para tratamento por binning.
O pipeline
Três passos: preparo → imputação → modelo. O preparo é uma casca em volta
da função acima e não trata nada por conta própria. Como o fit acontece dentro de
cada dobra da validação cruzada, o vazamento fica impossível por construção — na prova, a
dobra 1 aprendeu mediana de renda 5.386 enquanto as outras aprenderam 5.400.
O SimpleImputer é rede de segurança, não tratamento: hoje recebe zero nulos.
Existe para o campo vazio inesperado vindo do app. E todos os passos devolvem
DataFrame, para que o SHAP explique razao_divida e não
feature 4.
Modelagem
validação cruzada estratificada, 5 dobras, sempre sobre o treino
Métricas: ROC AUC, PR AUC e acurácia. Nenhuma delas significa nada sem o chão ao lado — dado que a prevalência é 6,684%, o chão é ROC AUC 0,5000, PR AUC 0,066844 e acurácia 0,933156.
Primeira rodada: o chão e a árvore sem poda
| Modelo | ROC AUC | PR AUC | Acurácia | ROC AUC treino | Gap |
|---|---|---|---|---|---|
| Dummy (prior) | 0,500000 | 0,066844 | 0,933156 | 0,500000 | 0,0000 |
| Decision Tree | 0,614817 | 0,123110 | 0,898400 | 0,999997 | 0,3852 |
A árvore tem acurácia menor que o Dummy — 0,8984 contra 0,9332 — e mesmo assim é o modelo melhor. Antes disso, a mesma inversão já tinha aparecido nas políticas manuais da Fase 2, e no F1, que elege como melhor a política de custo pior.
ROC AUC de 0,999997 no treino contra 0,614817 na validação. A árvore com
max_depth=None cresce até isolar cada cliente numa folha própria: não aprendeu o
padrão da inadimplência, decorou os 112.500 clientes do treino.
Onde exatamente começa o overfitting
Catorze tamanhos testados, cada um com validação cruzada de 5 dobras.
| max_depth | ROC AUC val. | PR AUC val. | Acurácia | ROC AUC treino | Gap |
|---|---|---|---|---|---|
| 1 | 0,656765 | 0,188747 | 0,933156 | 0,656765 | 0,000000 |
| 2 | 0,777162 | 0,256417 | 0,935111 | 0,778588 | 0,001426 |
| 3 | 0,804409 | 0,295887 | 0,934800 | 0,806164 | 0,001756 |
| 4 | 0,827789 | 0,330126 | 0,935067 | 0,828656 | 0,000866 |
| 5 | 0,843437 | 0,348510 | 0,935298 | 0,845523 | 0,002086 |
| 6 | 0,845424 | 0,350996 | 0,935547 | 0,851183 | 0,005759 |
| 7 | 0,845838 | 0,348320 | 0,935013 | 0,857709 | 0,011872 |
| 8 | 0,840676 | 0,337712 | 0,934542 | 0,862608 | 0,021932 |
| 10 | 0,811131 | 0,306381 | 0,932578 | 0,876530 | 0,065399 |
| 12 | 0,765853 | 0,277735 | 0,930267 | 0,893534 | 0,127681 |
| 15 | 0,683984 | 0,228595 | 0,925724 | 0,926492 | 0,242508 |
| 20 | 0,609835 | 0,173011 | 0,915022 | 0,978630 | 0,368795 |
| 30 | 0,617591 | 0,129391 | 0,900587 | 0,999747 | 0,382157 |
| sem limite | 0,614817 | 0,123110 | 0,898400 | 0,999997 | 0,385180 |
Melhor por ROC AUC: max_depth = 7 → 0,845838. Ganho de +0,231 sobre a árvore sem poda (0,614817), e o gap treino-validação cai de 0,385180 para 0,011872 — uma redução de 97%.
Pela regra de parcimônia de um desvio-padrão, max_depth = 5 já entrega 0,843437, dentro de um desvio do melhor, com metade da complexidade.
Nenhum dos 14 tamanhos atinge a meta contratada de 0,85. A árvore isolada chega perto do teto do que consegue — falta o passo para ensemble e boosting.
A árvore por dentro: três níveis
Uma árvore de profundidade 3, treinada na base completa de treino. Oito folhas, e apenas 3 das 16 features entram nela.
| Feature | Importância |
|---|---|
| atrasos_90_mais_dias | 0,712731 |
| uso_limite_rotativo | 0,153212 |
| atrasos_60_89_dias | 0,134057 |
Com três níveis a árvore não tem espaço para renda, idade, dependentes ou as features criadas. Ela gasta toda a profundidade disponível em comportamento de pagamento — o que é coerente com o negócio, e mostra por que a explicabilidade por SHAP será necessária: a importância global esconde que o resto das variáveis só aparece em árvores mais profundas.
Random Forest: profundidade contra desbalanceamento
Vinte e quatro configurações — oito profundidades por três tratamentos de
class_weight. Com prevalência de 6,684%, balanced daria à classe
inadimplente 14× mais peso que à adimplente.
| max_depth | sem peso | balanced | balanced_subsample |
|---|---|---|---|
| 4 | 0,855417 | 0,856014 | 0,856008 |
| 6 | 0,859684 | 0,859005 | 0,858846 |
| 8 | 0,861513 | 0,859153 | 0,858964 |
| 10 | 0,862885 | 0,855958 | 0,856160 |
| 12 | 0,862547 | 0,850509 | 0,850461 |
| 15 | 0,860536 | 0,841522 | 0,841628 |
| 20 | 0,852562 | 0,836217 | 0,837266 |
| sem limite | 0,837859 | 0,833871 | 0,834146 |
17 das 24 configurações passam de 0,85. A melhor é
max_depth=10 sem class_weight: ROC AUC 0,862885 e PR AUC 0,397012
— 5,9× o acaso. Ganho de +0,017 sobre a melhor árvore isolada e +0,248 sobre a árvore sem
poda.
Pela regra de um desvio-padrão, max_depth=6 (0,859684) já basta, com gap
treino-validação de 0,0056 contra 0,0322 da profundidade 10.
balanced e balanced_subsample produzem resultados praticamente
idênticos. No painel da direita, a acurácia dos modelos balanceados sobe conforme a
floresta piora, chegando ao valor do modelo sem peso justamente onde o ROC AUC é o pior de
todos.Split interno do treino (80/20), max_depth=10. A taxa real de inadimplência
nesse recorte é 6,6844%.
| class_weight | ROC AUC | Brier | Prob. média | Prob. mediana | Negaria com p* = 9,09% |
|---|---|---|---|---|---|
| sem peso | 0,866744 | 0,048669 | 0,066968 | 0,022945 | 18,80% |
| balanced | 0,861174 | 0,125051 | 0,304303 | 0,218092 | 86,97% |
| balanced_subsample | 0,861253 | 0,125171 | 0,304058 | 0,218473 | 86,37% |
Sem peso, a probabilidade média (0,066968) bate com a taxa real (0,066844) na terceira casa
decimal. Com balanced, o modelo prevê 30,4% de inadimplência onde a realidade é
6,7% — e o Brier mais que dobra. A consequência é operacional, não estatística: aplicando o
limiar econômico de 9,09% definido na Fase 1, o modelo balanceado negaria 87% da
carteira contra 18,8% do modelo sem peso.
É o mesmo raciocínio que já apareceu na Fase 1 por outro caminho: negar demais custa mais
caro que aprovar demais. Decisão: Random Forest sem class_weight.
O desbalanceamento se trata no limiar, que é onde ele é um problema de decisão — não nos pesos,
onde ele vira um problema de probabilidade errada.
O que cada folha significa para a operação
| Folha | Clientes | Inadimplentes | Taxa | × a base |
|---|---|---|---|---|
| 14 | 365 | 251 | 68,77% | 10,29× |
| 13 | 1.975 | 1.029 | 52,10% | 7,79× |
| 11 | 1.005 | 495 | 49,25% | 7,37× |
| 7 | 1.903 | 653 | 34,31% | 5,13× |
| 10 | 2.951 | 846 | 28,67% | 4,29× |
| 4 | 1.725 | 276 | 16,00% | 2,39× |
| 6 | 22.088 | 2.283 | 10,34% | 1,55× |
| 3 | 80.488 | 1.687 | 2,10% | 0,31× |
Oito perguntas separam uma folha com 68,77% de inadimplência de outra com 2,10% — uma diferença de 33×. A folha 3 sozinha carrega 80.488 clientes, 71,5% da carteira, com risco três vezes menor que a média. É esse contraste que uma política de crédito consegue explorar.
Repare que só duas folhas saem rotuladas como inadimplente. As folhas com 52,10% e 49,25%
são classificadas como adimplente porque o predict() padrão usa limiar 0,5. Com o
limiar econômico de 9,09%, seis das oito folhas seriam negadas. O limiar não é
detalhe de implementação — é a decisão de negócio.
Boosting: XGBoost e LightGBM
Busca aleatória com 25 sorteios por modelo, mesmas cinco dobras,
scoring='roc_auc' e random_state=42. O pipeline inteiro entra na
busca, então cada candidato refaz a preparação por dobra.
| Modelo | Padrão | Ajustado | Ganho | Gap antes | Gap depois |
|---|---|---|---|---|---|
| XGBoost | 0,851163 | 0,864800 | +0,013637 | 0,077153 | 0,014923 |
| LightGBM | 0,861576 | 0,865079 | +0,003504 | 0,040658 | 0,012633 |
Os dois convergiram para o mesmo desenho, por caminhos diferentes: taxa de
aprendizado baixa (~0,018), muitas árvores e regularização forte. O LightGBM
conteve a complexidade com num_leaves=15; o XGBoost, com
min_child_weight=50. Nos dois casos a busca ganhou menos em desempenho do que
em estabilidade — o gap treino-validação do XGBoost caiu de 0,077 para 0,015.
Taxa real de inadimplência no recorte de calibração: 0,066844. Sem ajuste, os dois
modelos acertam a probabilidade média na terceira casa decimal. Com
scale_pos_weight ou is_unbalance, o Brier quase triplica e o
limiar econômico passaria a negar quase 80% da carteira.
| Modelo | Ajuste | ROC AUC | Acurácia | Brier | Prob. média | Negaria com p* = 9,09% |
|---|---|---|---|---|---|---|
| XGBoost | sem ajuste | 0,864800 | 0,937458 | 0,048292 | 0,067202 | 18,72% |
| XGBoost | scale_pos_weight = 13,96 | 0,862543 | 0,816044 | 0,131569 | 0,302076 | 77,64% |
| LightGBM | sem ajuste | 0,865079 | 0,937209 | 0,048278 | 0,067185 | 18,71% |
| LightGBM | is_unbalance = True | 0,864582 | 0,795618 | 0,142028 | 0,316897 | 78,44% |
| LightGBM | class_weight = balanced | 0,864528 | 0,798924 | 0,141757 | 0,318426 | 80,47% |
Comparação final dos cinco candidatos
Mesmas dobras para todos, cada um na melhor configuração encontrada.
| Modelo | ROC AUC | PR AUC | Acurácia | PR AUC / acaso | Gap | Meta 0,85 |
|---|---|---|---|---|---|---|
| Dummy (prior) | 0,500000 | 0,066844 | 0,933156 | 1,000× | 0,0000 | não |
| Decision Tree (d=7) | 0,845838 | 0,348320 | 0,935013 | 5,211× | 0,0119 | não |
| Random Forest (d=10) | 0,862885 | 0,397012 | 0,937120 | 5,939× | 0,0322 | sim |
| XGBoost | 0,864800 | 0,402821 | 0,937458 | 6,026× | 0,0149 | sim |
| LightGBM | 0,865079 | 0,402528 | 0,937209 | 6,022× | 0,0126 | sim |
LightGBM e XGBoost estão separados por 0,00028 de ROC AUC, contra um desvio de 0,0034 entre dobras. O XGBoost venceu em uma das cinco dobras e lidera em PR AUC (0,402821) e em acurácia (0,937458).
Declarar vencedor por essa margem seria escolher ruído. O desempate real é o custo da carteira sob o limiar econômico, e isso é Fase 5. Por ora o LightGBM segue como candidato principal — com o XGBoost mantido como alternativa viva, não descartado.
Avaliação
37.500 clientes nunca vistos — a leitura que vale
A base de teste foi separada na Fase 3 e não foi tocada desde então: nenhuma mediana, nenhum hiperparâmetro, nenhuma escolha de modelo passou por ela. Cada candidato foi treinado no conjunto completo de treino e avaliado uma única vez aqui. Depois desta leitura o teste está gasto — usá-lo de novo para escolher qualquer coisa o transformaria em um segundo conjunto de validação.
Na validação cruzada o desvio vinha das cinco dobras. Aqui há uma leitura por modelo — não existe desvio entre dobras. O desvio vem de bootstrap: 2.000 reamostragens do teste com reposição, recalculando a métrica em cada uma.
O detalhe que faz a comparação funcionar: as mesmas reamostras valem para todos os modelos. Assim a diferença entre dois modelos é medida cliente a cliente, e não como a diferença de duas médias independentes.
| Modelo | ROC AUC | Desvio | IC 95% | PR AUC | Acurácia | Brier |
|---|---|---|---|---|---|---|
| Dummy (prior) | 0,500000 | 0,000000 | — | 0,066827 | 0,933173 | 0,062361 |
| Decision Tree | 0,848406 | 0,004172 | 0,840424 – 0,856674 | 0,349051 | 0,934987 | 0,050820 |
| Random Forest | 0,865808 | 0,003769 | 0,858411 – 0,873113 | 0,404627 | 0,937013 | 0,048831 |
| XGBoost | 0,869250 | 0,003679 | 0,861951 – 0,876374 | 0,409090 | 0,937760 | 0,048514 |
| LightGBM | 0,869171 | 0,003688 | 0,862037 – 0,876416 | 0,410106 | 0,938373 | 0,048472 |
O teste confirmou a validação cruzada
| Modelo | Validação cruzada | Teste | Diferença |
|---|---|---|---|
| Decision Tree | 0,845838 | 0,848406 | +0,002568 |
| Random Forest | 0,862885 | 0,865808 | +0,002923 |
| XGBoost | 0,864800 | 0,869250 | +0,004450 |
| LightGBM | 0,865079 | 0,869171 | +0,004092 |
Os quatro ficaram acima da validação cruzada. Nenhuma degradação: o protocolo da Fase 3 — preparação aprendida dentro de cada dobra, teste isolado desde o início — segurou. A diferença positiva soma dois efeitos esperados: o modelo final treina com 100% do treino, contra 80% em cada dobra, e este recorte de teste saiu marginalmente mais fácil que a média das dobras.
Existe diferença real entre os modelos?
Diferença pareada contra o XGBoost, que teve o maior ROC AUC. Se o intervalo de confiança cruza o zero, a vantagem não se distingue de ruído.
| Concorrente | Diferença média | Desvio | IC 95% | XGBoost venceu em | Diferença real? |
|---|---|---|---|---|---|
| Dummy (prior) | 0,369350 | 0,003679 | 0,361951 – 0,376374 | 100,0% | sim |
| Decision Tree | 0,020854 | 0,001956 | 0,016998 – 0,024608 | 100,0% | sim |
| Random Forest | 0,003441 | 0,000786 | 0,001916 – 0,004937 | 100,0% | sim |
| LightGBM | 0,000062 | 0,000349 | −0,000625 – 0,000741 | 56,6% | não |
A vantagem do boosting sobre o Random Forest é real. O intervalo vai de 0,0019 a 0,0049 sem tocar o zero, e o XGBoost venceu em 2.000 de 2.000 reamostras. Pequena, mas não é sorte.
A vantagem do XGBoost sobre o LightGBM não é real. O intervalo cruza o zero e o XGBoost venceu em 56,6% das reamostras — quase um cara ou coroa. O empate que a validação cruzada sugeriu está confirmado em dado novo.
Calibração em dados nunca vistos
| Modelo | Brier | Prob. média | Taxa real | Erro | Negaria com p* = 9,09% |
|---|---|---|---|---|---|
| Decision Tree | 0,050820 | 0,066811 | 0,066827 | −0,000016 | 21,52% |
| Random Forest | 0,048831 | 0,066343 | 0,066827 | −0,000484 | 18,49% |
| XGBoost | 0,048514 | 0,066503 | 0,066827 | −0,000324 | 18,20% |
| LightGBM | 0,048472 | 0,066563 | 0,066827 | −0,000263 | 18,14% |
A decisão de não usar peso de classe se confirma da forma mais direta possível: os quatro erram a probabilidade média por menos de 0,0005 em dados que nunca viram. O limiar econômico de 9,09% definido na Fase 1 é aplicável como está — negaria cerca de 18% da carteira.
LightGBM. Lidera em PR AUC (0,410106), acurácia (0,938373) e Brier (0,048472); perde em ROC AUC por 0,000062 — diferença que o bootstrap mostra ser ruído. Quando o critério principal empata, decidem os secundários.
O XGBoost permanece como alternativa equivalente. Trocar um pelo outro não muda o resultado de forma mensurável.
Onde o projeto está
e o que vem em seguida
Os cinco candidatos foram testados. A árvore isolada parou em 0,8458, abaixo da meta; o Random Forest atravessou a linha com 0,8629; e o boosting fechou em 0,8651 (LightGBM) e 0,8648 (XGBoost) — empate técnico entre os dois.
Vale registrar a escala do que sobrou: entre a árvore podada e o melhor boosting há 0,019 de ROC AUC. Entre a árvore sem poda e a árvore podada havia 0,231. A escolha do algoritmo importou dez vezes menos que a decisão de podar — e as duas juntas importam menos que os problemas de dados encontrados na Fase 2.
O teste foi aberto e confirmou tudo: 0,8692 de ROC AUC contra a meta contratada de 0,85, com calibração intacta. O que falta da Fase 5 é traduzir isso em decisão: varrer o limiar pelo custo da carteira — não pela acurácia — e produzir a explicabilidade individual por SHAP, que é o que permite dizer a um cliente e a um regulador por que aquele crédito foi negado.
O teste, a partir de agora, está gasto. Qualquer ajuste que venha a ser feito olhando para ele deixa de ser avaliação e vira seleção.