Estudo de caso · NLP · Classificação de intenções

Você precisa mesmo de um Transformer?
ML clássico vs fine-tuning em 77 intenções bancárias

Um XGBoost com feature engineering em 3 frentes contra um DistilBERT fine-tunado: mesmo dataset, mesmo split, mesmas métricas. O placar surpreende: empate técnico, e quem vence de verdade é a combinação dos dois.

91,35%XGBoost Híbrido · F1 Macro no teste
TF-IDF+SVD · SBERT · heurísticas → 491 features
90,85%DistilBERT fine-tunado · F1 Macro no teste
66M parâmetros · 5 épocas · melhor checkpoint
vencedor92,80%Ensemble (soft voting) · F1 Macro no teste
média das probabilidades 50/50, sem retreino

Avaliação em 1.963 frases de teste jamais vistas no treino · dataset Banking77 (PolyAI) · métricas e exemplos desta página extraídos diretamente dos artefatos reais de avaliação.

01 · O desafio

77 intenções que se parecem demais

O Banking77 reúne perguntas reais de clientes de um banco digital. O problema não é o volume, é a granularidade: são 77 intenções, e muitas delas são vizinhas semânticas separadas por nuances ("a transferência está pendente" vs "a transferência demora quanto tempo?").

13.083
frases reais de clientes, curtas e diretas (~12 palavras em média)
77
intenções distintas, várias separadas apenas por nuances
70/15/15
split estratificado (treino/validação/teste), random_state=42, idêntico para os dois modelos
1.963
frases de teste que nenhum modelo viu durante o treino
Por que isso é difícil? Com 77 classes, um chute aleatório acerta 1,3% das vezes. E as classes formam "famílias" traiçoeiras: pending_transfer, transfer_timing e balance_not_updated_after_bank_transfer falam todas de transferências que ainda não chegaram, mas cada uma pede uma resposta diferente do banco.

🎯 Teste você mesmo: consegue vencer os modelos?

Três frases reais do conjunto de teste. Escolha a intenção correta e depois veja como cada modelo se saiu nessa mesma frase.

I can't pull my card out of the ATM. Help me.
("Não consigo tirar meu cartão do caixa eletrônico. Me ajude.")
✅ card_swallowed (cartão engolido pela máquina). Aqui o DistilBERT acertou e o XGBoost errou (previu declined_cash_withdrawal): entender que a máquina reteve o cartão é compreensão de cena, não de palavras-chave.
How long until my transfer goes through?
("Quanto tempo até minha transferência ser concluída?")
✅ transfer_timing (dúvida sobre prazo). Aqui foi o XGBoost que acertou (o bigrama "how long" é uma assinatura lexical fortíssima), enquanto o DistilBERT escorregou para pending_transfer. No ensemble, a confiança do XGBoost venceu o desempate.
Why is your exchange rate so bad?
("Por que a taxa de câmbio de vocês é tão ruim?")
✅ card_payment_wrong_exchange_rate: é uma reclamação sobre a taxa aplicada, não uma consulta de cotação. Só o DistilBERT captou o tom; o XGBoost viu "exchange rate" e previu a classe de consulta exchange_rate.
A moral do quiz: cada paradigma enxerga o problema de um jeito: pistas lexicais de um lado, compreensão contextual do outro. É exatamente essa diferença de "olhar" que vai explicar o resultado final do benchmark.
02 · Os competidores

Dois paradigmas, duas filosofias

De um lado, o time do feature engineering: transformar o texto em números informativos e deixar o gradient boosting decidir. Do outro, o time do fine-tuning end-to-end: entregar o texto cru a um transformer e deixar que ele aprenda tudo sozinho.

Abordagem 1 XGBoost Híbrido

O texto é decomposto por três frentes complementares de features, concatenadas em um vetor de 491 dimensões que alimenta o XGBoost.

🔤
Frente A · Lexical (TF-IDF → LSA, 100d)TF-IDF com 5.000 termos e bigramas ("top up", "not working"), comprimido por TruncatedSVD para 100 "tópicos latentes" do vocabulário bancário.
🧭
Frente B · Semântica (SBERT, 384d)Embeddings do all-MiniLM-L6-v2 (22M parâmetros, congelado, nunca é treinado). Captura o significado da frase inteira.
💼
Frente C · Heurísticas de negócio (7d)Sinais artesanais do domínio: contagem de palavras, "!"/"?", símbolos monetários ($ £ €), referências de tempo e palavras de urgência ("stolen", "asap").
▼ concatenação (491 features), sem StandardScaler
🌳
XGBoost: 500 árvores (hist)max_depth=6, lr=0.1, subsample/colsample 0.8, early stopping de 20 rodadas na validação.
treina em minutos, em CPUfeature importance nativa~500MB RAM

Abordagem 2 DistilBERT Fine-tunado

O transformer recebe o texto cru e ajusta todos os seus 66M de parâmetros para a tarefa. Nenhuma feature manual: a representação é aprendida.

✂️
Tokenizer WordPiece (máx. 128 tokens)Frases do Banking77 são curtas, então 128 tokens sobram e economizam memória/tempo vs 512.
🧠
DistilBERT: 6 camadas transformerdistilbert-base-uncased: destilação do BERT com ~97% da performance, 40% menor e 60% mais rápido.
🎚️
Fine-tuning completo em 5 épocaslr=3e-5, batch 32, warmup 200 passos, weight decay 0.01, fp16 em GPU. Avaliação a cada época na validação.
▼ melhor checkpoint por F1 macro (validação)
🎯
Cabeça softmax: 77 classesO checkpoint da época 5 (F1 val 0.9073) foi o selecionado para o benchmark.
treino exige GPU p/ ser práticozero feature engineering~1.5GB RAM
Confissão importante (e didática): o "clássico" aqui não é 100% clássico: a Frente B usa um mini-transformer congelado como extrator de features. A pergunta justa deste benchmark é, portanto: "embeddings prontos + gradient boosting" rendem tanto quanto fine-tuning end-to-end? Spoiler: rendem.
🥇 Regra de ouro MLOps do projeto: sem StandardScaler nas features do XGBoost. Árvores de decisão dividem por threshold em features individuais, uma operação invariante a escala monotônica. Remover o scaler elimina uma transformação matricial por inferência (~5-10% de latência), e elimina o risco clássico de leakage por um fit_transform acidental no teste.

⚖️ Split idêntico

Ambos treinam nas mesmas 9.157 frases e validam nas mesmas 1.963, split estratificado com random_state=42. Nenhuma vantagem de dados para ninguém.

🚪 Teste intocado

A validação serve para early stopping e escolha de checkpoint. As 1.963 frases de teste só aparecem uma única vez, na avaliação final.

⏱️ Latência de produção

Medida frase a frase (single-sample, como um chatbot real), com warmup e perf_counter: P50 e P95 sobre centenas de execuções.

03 · Resultados

O clássico empatou. O ensemble venceu.

No teste cego com 1.963 frases, o XGBoost híbrido ficou 0,5 ponto à frente do DistilBERT em F1 macro, um empate técnico que já derruba o mito de que "só transformer resolve". A surpresa real vem depois: combinando os dois, o F1 sobe quase 1,5 ponto.

F1-Score no conjunto de teste

F1 macro trata as 77 classes igualmente; F1 weighted pondera pelo nº de exemplos. Barras em escala de 0 a 100%.

XGBoost HíbridoDistilBERTEnsemble
XGBoost HíbridoF1 macro
0.9135
F1 weighted
0.9117

DistilBERTF1 macro
0.9085
F1 weighted
0.9079

Ensemble (soft voting)F1 macro
0.9280
F1 weighted
0.9271

Acurácia: 91,2% (XGB) · 90,8% (DistilBERT) · 92,7% (Ensemble). Curiosidade: o plano do projeto previa 85-88% para o XGBoost híbrido, e ele entregou 91,4% e superou a própria expectativa.

Latência de inferência single-sample

Tempo de ponta a ponta para classificar uma frase (como em um chatbot), incluindo feature engineering no caso do XGBoost. Medido nesta máquina, com GPU disponível (RTX 4070 Laptop).

XGBoost HíbridoP50
13,6 ms
P95
15,1 ms

DistilBERTP50
9,4 ms
P95
10,4 ms

EnsembleP50
34,0 ms
P95
36,2 ms

⚠️ Leitura honesta: com GPU, o DistilBERT é o mais rápido, e a latência do XGBoost híbrido é dominada pelo encode do SBERT, não pelas árvores. Em servidor só-CPU o jogo inverte: transformers tipicamente sobem para dezenas ou centenas de ms, enquanto o pipeline clássico muda pouco. Latência é uma decisão de infraestrutura tanto quanto de modelo.

Como o DistilBERT aprendeu: a curva de fine-tuning

F1 macro e loss na validação ao fim de cada época. Uma época não basta: o F1 salta de 0.57 para 0.91 ao longo de 5 épocas.

1.000.900.800.700.600.50 2.52.01.51.00.50.0 Época 1Época 2Época 3Época 4Época 5 0.5680.8270.8950.9040.907 melhor checkpoint → usado no benchmark F1 macro (val.) loss (val.)

Enquanto isso, o XGBoost híbrido converge em minutos de CPU, com early stopping na validação, sem GPU, sem checkpoints, sem scheduler. O custo de treino das duas abordagens vive em ordens de grandeza diferentes.

Onde os dois sofrem: as 8 intenções mais difíceis

F1 por classe (média dos dois modelos, as piores primeiro). Repare: quase todas são da família "transferências e recargas que ainda não aconteceram".

XGBoostDistilBERTEnsemble
topping_up_by_card
0.78
0.74
0.76

balance_not_updated_after_bank_transfer
0.79
0.75
0.79

why_verify_identity
0.86
0.69
0.84

transfer_not_received_by_recipient
0.84
0.72
0.78

verify_my_identity
0.83
0.75
0.86

transfer_timing
0.81
0.80
0.85

pending_transfer
0.77
0.87
0.79

card_delivery_estimate
0.84
0.80
0.86

No extremo oposto, 3 intenções saíram perfeitas (F1 = 1.00) nos dois modelos: change_pin, get_physical_card e passcode_forgotten. Vocabulário inconfundível.

Os pares que enganam os modelos

Principais confusões no teste (intenção verdadeira → intenção predita) e quantas vezes cada modelo caiu nelas.

Confusão (verdade → predição)XGBBERTPor que confunde
card_delivery_estimate → card_arrival57"quando chega meu cartão?" vs "meu cartão já chegou?". Praticamente a mesma frase
verify_my_identity → why_verify_identity35"como verifico?" vs "por que verificar?". Uma palavra muda a intenção
transfer_timing ↔ balance_not_updated_after_bank_transfer64a mesma ansiedade ("cadê minha transferência?") com causas diferentes
transfer_fee_charged → card_payment_fee_charged33taxa inesperada, mas foi na transferência ou no cartão?
wrong_exchange_rate_for_cash_withdrawal → card_payment_wrong_exchange_rate4-câmbio errado no saque vs no pagamento, só o contexto distingue
pending_top_up → top_up_failed-4recarga pendente vs falhada: o desfecho ainda não existe na frase

Leitura importante: os erros não são aleatórios: concentram-se em pares semanticamente legítimos, onde até um atendente humano hesitaria. É o "teto natural" do dataset aparecendo.

04 · O plot twist

Erros diferentes somam: a mágica do ensemble

Se dois modelos têm a mesma acurácia mas erram em frases diferentes, há informação grátis na mesa. Foi exatamente o que aconteceu: XGBoost e DistilBERT discordam em 9% das frases, e é nessa zona de discordância que o ensemble constrói sua vantagem.

Quem acerta cada uma das 1.963 frases de teste:

87,4%1.715✅✅ os dois acertam
zona tranquila
3,8%75✅ só o XGBoost acerta
pistas lexicais/heurísticas
3,4%67✅ só o DistilBERT acerta
nuance semântica
5,4%106❌❌ os dois erram
o teto do dataset

Soft voting em uma linha

Pens(classe) = 0.5 · Pxgb + 0.5 · Pbert

Nada de retreino: apenas a média das distribuições de probabilidade dos dois modelos. Quando um está confiante e o outro em dúvida, o confiante domina a decisão. Um desempate automático e gratuito.

105 de 142
resgates: nas frases em que exatamente um modelo acertava, o ensemble ficou com a resposta certa 74% das vezes
0
estragos: em nenhuma das 1.715 frases que ambos acertavam o ensemble trocou para uma resposta errada
+1,45pp
saldo final: F1 macro sobe de 0.9135 (melhor modelo isolado) para 0.9280, pagando o preço de rodar os dois modelos em sequência (~34 ms)
E o teto? Os dois modelos erram juntos em 106 frases (5,4%), em geral os pares genuinamente ambíguos da seção anterior. Nenhum ensemble desses dois modelos passaria de ~94,6% de acurácia: melhorar a partir daí exige outro tipo de informação (contexto da conversa, histórico do cliente), não outro classificador.
05 · Na prática

Frases reais, disputas reais

Casos do conjunto de teste em que os modelos discordaram, e o que cada disputa ensina sobre os dois paradigmas.

Why should I have to prove my identity?
verdade: why_verify_identityXGB: why_verify_identity ✓BERT: unable_to_verify_identity ✗Ensemble: unable_to_verify_identity ✗

O "Why" inicial é uma pista lexical que o TF-IDF captura com força. Detalhe honesto: aqui a confiança do BERT era tão alta que arrastou o ensemble para o erro: soft voting não é infalível.

How long until my transfer goes through?
verdade: transfer_timingXGB: transfer_timing ✓BERT: pending_transfer ✗Ensemble: transfer_timing ✓

O bigrama "how long" é praticamente uma assinatura da classe de prazo, exatamente o tipo de padrão em que features n-gram brilham.

Tell me how to renew my new card?
verdade: activate_my_cardXGB: activate_my_card ✓BERT: card_about_to_expire ✗Ensemble: activate_my_card ✓

O verbo "renew" seduz o transformer para a classe de expiração; o léxico "new card" mantém o XGBoost ancorado na ativação.

Is there a problem with the top up system? My transaction hasn't gone through properly
verdade: pending_top_upXGB: pending_top_up ✓BERT: top_up_failed ✗Ensemble: pending_top_up ✓

"hasn't gone through" ainda não é "failed": a nuance de estado pendente favoreceu a combinação léxico + heurísticas.

Can I get support?
verdade: country_supportXGB: age_limit ✗BERT: country_support ✓Ensemble: country_support ✓

Quatro palavras, zero sobreposição com o nome da classe. Sem pista lexical, só a semântica aprendida no fine-tuning ("support" = disponibilidade do serviço) resolve.

I can't pull my card out of the ATM. Help me.
verdade: card_swallowedXGB: declined_cash_withdrawal ✗BERT: card_swallowed ✓Ensemble: card_swallowed ✓

Nenhuma palavra da frase aparece no rótulo: é preciso entender a cena (a máquina reteve o cartão). Compreensão contextual pura.

Why is your exchange rate so bad?
verdade: card_payment_wrong_exchange_rateXGB: exchange_rate ✗BERT: card_payment_wrong_exchange_rate ✓Ensemble: exchange_rate ✗

Reclamar da taxa aplicada ≠ perguntar a cotação. O BERT leu o tom; o XGBoost viu as palavras "exchange rate" e, desta vez, arrastou o ensemble junto.

show me how to transfer to my account
verdade: transfer_into_accountXGB: beneficiary_not_allowed ✗BERT: transfer_into_account ✓Ensemble: transfer_into_account ✓

Frase informal, sem pontuação, vocabulário genérico: o terreno onde representações contextuais superam contagens de termos.

How long until my transfer goes through?
verdade: transfer_timingXGB: transfer_timing ✓BERT: pending_transfer ✗Ensemble: transfer_timing ✓

Divergência resolvida a favor do XGBoost: sua distribuição de probabilidade estava mais concentrada (mais confiante) que a do BERT.

I can't pull my card out of the ATM. Help me.
verdade: card_swallowedXGB: declined_cash_withdrawal ✗BERT: card_swallowed ✓Ensemble: card_swallowed ✓

Agora o desempate pende para o BERT: o XGBoost estava em dúvida entre várias classes de saque, e a média premiou a convicção do transformer.

Is there a problem with the top up system? My transaction hasn't gone through properly
verdade: pending_top_upXGB: pending_top_up ✓BERT: top_up_failed ✗Ensemble: pending_top_up ✓

Mais um resgate: dos 142 casos em que exatamente um modelo acertava, 105 terminaram assim, com o ensemble do lado certo.

show me how to transfer to my account
verdade: transfer_into_accountXGB: beneficiary_not_allowed ✗BERT: transfer_into_account ✓Ensemble: transfer_into_account ✓

O padrão geral: modelos com "olhares" diferentes raramente erram juntos, e é isso que torna a média das probabilidades tão eficaz.

06 · Conclusões

Qual usar? Depende do que custa mais para você

Não existe vencedor absoluto, existe o modelo certo para cada restrição de produção. O resumo executivo da disputa:

Critério🌳 XGBoost Híbrido🧠 DistilBERT🤝 Ensemble
F1 macro (teste)0.91350.90850.9280
Latência com GPU (P50)13,6 ms9,4 ms34,0 ms
Deploy só-CPUpouco muda (~15-40 ms)degrada muito (50-200 ms típicos)herda o pior dos dois
Custo de treinominutos, em CPU~30-60 min sem GPU; minutos com GPUzero (reusa os dois)
Interpretabilidadealta (feature importance por frente)baixa (atenção ≠ explicação)média (decomponível por modelo)
Memória de inferência~500 MB~1,5 GB~2 GB
Novas features de negócioplugáveis (Frente C)exigem retreino completoplugáveis no lado XGB

Escolha o XGBoost híbrido se…

…o deploy é CPU-only ou edge, o orçamento de latência é apertado, o time precisa explicar decisões (auditoria/compliance) ou o modelo será retreinado com frequência.

Escolha o DistilBERT se…

…há GPU na inferência, as frases fogem do vocabulário conhecido (semântica manda), ou você quer zero manutenção de features, além de um caminho natural para modelos multilíngues.

Escolha o ensemble se…

…cada ponto de F1 vale dinheiro (roteamento errado = cliente irritado) e 34 ms de P50 cabem no SLA. É o ganho mais barato do projeto: nenhum retreino, +1,45pp.

🎓 O que este benchmark ensina

  1. Comece pelo baseline forte.

    O "clássico" empatou com o transformer por uma fração do custo de treino. Sem esse baseline, o fine-tuning pareceria indispensável, e ninguém saberia que não era.

  2. Feature engineering não morreu, mudou de forma.

    Embeddings congelados viraram feature: a Frente B entrega a semântica do transformer sem pagar o fine-tuning. As 7 heurísticas artesanais continuam somando sinal que embeddings diluem.

  3. Modelos que erram diferente valem mais juntos.

    9% de divergência entre dois modelos de ~91% virou +1,45pp de F1 com uma média de probabilidades. Antes de escalar o modelo, verifique se a diversidade que você já tem está sendo usada.

  4. Latência é decisão de infraestrutura, não só de modelo.

    Com GPU, o DistilBERT é o mais rápido; em CPU, o jogo inverte completamente. O mesmo benchmark, em outra máquina, contaria outra história. Meça no hardware do deploy.

  5. A métrica agregada esconde o que interessa.

    91% de F1 macro parece ótimo até você ver why_verify_identity a 0.69 no BERT. A análise por classe e por par confundido é onde moram as decisões de produto.

  6. Disciplina de avaliação é inegociável.

    Split estratificado idêntico, validação para escolher, teste visto uma única vez, latência com warmup e percentis. Sem isso, qualquer comparação é ruído.

Reproduza em casa: python -m src.train_xgboost → python -m src.train_hf → python -m src.evaluate. O repositório inclui ainda um app Streamlit que roda os três modelos lado a lado em qualquer frase que você digitar. Código, seeds e instruções completas no GitHub.