1

Por que ML Clássico ainda é dominante em 2026

Existe uma percepção popular — alimentada por threads no LinkedIn e demos de IA generativa — de que LLMs tornaram obsoletos os modelos clássicos de Machine Learning. Isso é falso. Em 2026, a vasta maioria das previsões críticas em produção em fintechs, varejo, e-commerce, telecom, ad-tech e healthcare é feita por modelos tabulares: XGBoost, LightGBM, CatBoost, Random Forest e regressão logística.

LLMs e ML clássico resolvem problemas diferentes. LLMs são imbatíveis em raciocínio sobre linguagem natural, geração de conteúdo e tarefas que exigem conhecimento de mundo. Mas quando o problema é "dado este vetor de 200 features numéricas e categóricas, qual a probabilidade do cliente dar default em 30 dias?" — você quer XGBoost.

Cases reais onde ML clássico vence LLMs

💳
Nubank — Categorização de Transações

Modelos gradient boosting categorizam centenas de milhões de transações/mês com latência <10ms. Um LLM seria ~5000x mais caro e 100x mais lento, sem ganho real em acurácia para input curto e estruturado.

🚨
Fraude Bancária em Tempo Real

Decisão de aprovar/bloquear cartão precisa retornar em <100ms. XGBoost + features comportamentais e de rede gráfica (Graph features) consegue. Um LLM, mesmo gpt-4o-mini, simplesmente não cabe no SLA.

📞
Churn de Telecom (Vivo, Claro, TIM)

Predição de churn em base de 80M+ clientes com 300+ features (planos, uso, atendimento). LightGBM treina em <30min em CPU, prevê em microssegundos. Já testado: LLMs com mesmos dados em forma textual têm AUC inferior.

🔍
Ranking de Busca e Recomendação

Mercado Livre, iFood, Globoplay — todos usam Learning to Rank (LambdaMART, XGBoost-Ranker) para ordenar resultados. LLMs entram como re-rankers no topo, mas o filtro grosso (de milhões para dezenas) é sempre tabular.

Quando usar ML clássico vs LLM

Dimensão ML Clássico (XGBoost / LightGBM) LLM (GPT-4o, Claude, Gemini)
Tipo de input Tabular: numérico + categórico Texto livre, imagens, áudio
Latência típica 0.5–10 ms / inferência 200–5000 ms / inferência
Custo/inferência ~R$ 0.0001 (100x menor) R$ 0.05+ (gpt-4o)
Escala viável Bilhões/dia em CPU comum Milhões/dia exige GPU + cache agressivo
Interpretabilidade Alta — SHAP, feature importance Baixa — black-box, chain-of-thought parcial
Auditoria regulatória Aceito por BACEN, BCB, EBA, Fed Ainda problemático para crédito/seguros
Determinismo 100% determinístico (mesma entrada → mesma saída) Não-determinístico mesmo com temp=0
Treino com 10k linhas Excelente — XGBoost brilha aqui Fine-tuning instável, prefira RAG
Treino com 100M linhas LightGBM lida em horas Fine-tuning custa dezenas de milhares de USD
Quando vence Fraude, crédito, churn, ranking, pricing NLU, geração, raciocínio, classificação textual aberta

Custo real: a diferença de 500x

Um cálculo simples ilustra por que arquitetos sêniores ainda escolhem ML clássico para a maioria dos problemas em produção. Considere uma fintech com 10 milhões de decisões/dia de score de crédito:

R$ 1.000/dia
XGBoost em CPU
10M inferências @ R$0.0001
R$ 500.000/dia
GPT-4o
10M inferências @ R$0.05
500x
Diferença de custo
Por decisão equivalente
R$ 182M/ano
Economia anual
Escolhendo ML clássico
💡
A regra prática do arquiteto sênior

Use LLM apenas quando o problema exige compreensão de linguagem natural, raciocínio multi-step ou geração de conteúdo. Para qualquer coisa que possa ser representada como tabela com colunas numéricas/categóricas, comece com XGBoost ou LightGBM. Se o ganho de um LLM for <3% de AUC sobre o modelo tabular, o ROI quase nunca compensa.

2

Estatística aplicada e A/B Testing

Antes de qualquer modelo, você precisa de estatística. A maior parte das decisões de produto em empresas data-driven (Booking, Spotify, iFood, Mercado Livre) é tomada por experimentos A/B testes — e não por modelos preditivos. A diferença entre um cientista de dados júnior e um sênior frequentemente é a sofisticação com que ele desenha e interpreta testes.

Fundamentos práticos

Três conceitos que você precisa internalizar:

📐
Distribuições

Normal, Lognormal, Bernoulli, Poisson. A escolha da distribuição certa para sua métrica determina qual teste estatístico é válido. Tempo de sessão? Lognormal. Conversão? Bernoulli. Compras/dia? Poisson ou Negative Binomial.

🎯
Intervalo de Confiança (IC)

Mais útil que o p-value sozinho. Um IC de 95% para uplift de [-0.5%, +3.2%] é honesto: o efeito pode ser nulo ou pequeno. Sempre reporte ICs no resultado de A/B test.

⚖️
P-value (com ressalvas)

Probabilidade de ver dados tão ou mais extremos sob H0 (efeito = 0). NÃO é probabilidade de H0 ser verdadeira. Threshold 0.05 é convenção, não lei. Use bayesian posterior se quiser falar em probabilidades.

📊
MDE — Minimum Detectable Effect

Menor uplift que seu teste tem poder de detectar. Se MDE = 2% e seu efeito real é 1%, você vai concluir "não significativo" mesmo havendo efeito. Calcule MDE ANTES do teste, não depois.

Erros comuns que invalidam testes

⚠️
Os 4 pecados capitais do A/B testing

1. Peeking — olhar resultados antes do tempo definido e parar quando "deu significativo". Infla taxa de falso positivo de 5% para 25%+.
2. Multiple comparisons — testar 20 métricas, declarar vitória na que deu p<0.05. Use correção de Bonferroni (alpha/n) ou Benjamini-Hochberg (FDR).
3. Simpson's paradox — uplift positivo geral pode esconder uplift negativo em todos os segmentos quando há mistura de tráfego desigual. Sempre quebre por segmento principal.
4. Sample Ratio Mismatch (SRM) — se você desenhou 50/50 mas chegou em 47/53, há um bug no random assignment. Teste com chi-square antes de olhar resultado.

Code: A/B test em Python com scipy

Python
A/B test de conversão com scipy + sample size
"""
A/B Test completo de uma feature de checkout.
Variante A (controle): checkout em 3 passos.
Variante B (teste): checkout em 1 passo.
Métrica primária: conversão (binária).
"""
import numpy as np
from scipy import stats
from statsmodels.stats.power import zt_ind_solve_power
from statsmodels.stats.proportion import proportions_ztest, confint_proportions_2indep

# ─── Passo 1: Calcular sample size ANTES de rodar o teste ──────────────────
# Você quer detectar uplift de 1.5pp sobre baseline de 12% (relative ~12.5%)
baseline_rate = 0.12          # conversão atual
mde_absolute = 0.015          # 1.5 ponto percentual de melhoria
alpha = 0.05                  # nível de significância
power = 0.80                  # poder estatístico (1 - beta)

# Cohen's h — effect size para proporções
p1, p2 = baseline_rate, baseline_rate + mde_absolute
effect_size = 2 * (np.arcsin(np.sqrt(p2)) - np.arcsin(np.sqrt(p1)))

n_por_grupo = zt_ind_solve_power(
    effect_size=effect_size,
    alpha=alpha,
    power=power,
    alternative='two-sided'
)
print(f"📐 Sample size necessário: {int(np.ceil(n_por_grupo)):,} usuários por grupo")
# → ~7,200 por grupo (14,400 total)

# ─── Passo 2: Simular dados do teste (na vida real, viriam do warehouse) ───
np.random.seed(42)
n_a = 7200
n_b = 7200
conversoes_a = np.random.binomial(1, 0.120, n_a)  # 12.0% conversão A
conversoes_b = np.random.binomial(1, 0.138, n_b)  # 13.8% conversão B

# ─── Passo 3: Sample Ratio Mismatch check ──────────────────────────────────
# Detecta bug de assignment ANTES de olhar a métrica
chi2, p_srm = stats.chisquare([n_a, n_b], f_exp=[(n_a+n_b)/2]*2)
if p_srm < 0.001:
    raise RuntimeError(f"⚠️ SRM detectado (p={p_srm:.4f}) — há bug no split. NÃO interprete o teste.")
print(f"✓ SRM check passou (p={p_srm:.3f})")

# ─── Passo 4: Teste de proporções (z-test) ─────────────────────────────────
sucessos = np.array([conversoes_a.sum(), conversoes_b.sum()])
amostras = np.array([n_a, n_b])
z_stat, p_valor = proportions_ztest(sucessos, amostras, alternative='two-sided')

# Intervalo de confiança 95% para a DIFERENÇA de proporções
ci_low, ci_high = confint_proportions_2indep(
    sucessos[1], n_b, sucessos[0], n_a,
    method='wald', compare='diff', alpha=0.05
)

# ─── Passo 5: Reportar resultado de forma honesta ──────────────────────────
taxa_a = sucessos[0] / n_a
taxa_b = sucessos[1] / n_b
uplift_abs = taxa_b - taxa_a
uplift_rel = uplift_abs / taxa_a * 100

print(f"\n📊 Resultados:")
print(f"  Variante A: {taxa_a:.4f} ({sucessos[0]:,}/{n_a:,})")
print(f"  Variante B: {taxa_b:.4f} ({sucessos[1]:,}/{n_b:,})")
print(f"  Uplift absoluto: {uplift_abs:+.4f} ({uplift_rel:+.2f}%)")
print(f"  IC 95% para diferença: [{ci_low:+.4f}, {ci_high:+.4f}]")
print(f"  z-stat: {z_stat:.3f}, p-valor: {p_valor:.4f}")

# Conclusão honesta — IC e p-value, não só p-value
if p_valor < alpha and ci_low > 0:
    print(f"\n✅ Variante B vence com significância estatística (p={p_valor:.4f}).")
    print(f"   Decisão: rollout 100% para B.")
elif p_valor < alpha and ci_high < 0:
    print(f"\n❌ Variante B é PIOR que A com significância (p={p_valor:.4f}).")
    print(f"   Decisão: matar a feature.")
else:
    print(f"\n🤷 Inconclusivo (p={p_valor:.4f}). IC inclui 0.")
    print(f"   Decisão: rodar mais tempo OU aceitar que efeito é <{mde_absolute*100}pp.")
Output esperado
📐 Sample size necessário: 7,176 usuários por grupo
✓ SRM check passou (p=1.000)

📊 Resultados:
  Variante A: 0.1190 (857/7,200)
  Variante B: 0.1383 (996/7,200)
  Uplift absoluto: +0.0193 (+16.22%)
  IC 95% para diferença: [+0.0083, +0.0303]
  z-stat: 3.453, p-valor: 0.0006

✅ Variante B vence com significância estatística (p=0.0006).
   Decisão: rollout 100% para B.

# Note que reportamos:
# 1. Sample size CALCULADO antes (não escolhido depois)
# 2. SRM check (defesa contra bug de assignment)
# 3. IC além do p-value — IC [+0.83pp, +3.03pp] é honesto
# 4. Conclusão prática (rollout/kill/inconclusivo)
3

Pipeline Clássico de ML — Supervised

Um pipeline de ML supervisionado bem construído tem 6 etapas claras. Pular qualquer uma é receita para data leakage, overfitting ou um modelo que parece bom em validação e despenca em produção. Esta é a anatomia que aplica para classificação binária, regressão e ranking — desde Kaggle até modelos de bilhões de inferências/dia.

Feature Engineering — onde modelos são vencidos ou perdidos

XGBoost com features ruins perde para regressão logística com features bem desenhadas. Feature engineering ainda é o maior diferencial competitivo em ML tabular em 2026.

🔤
Encoding categórico

One-hot para baixa cardinalidade (<10). Target encoding para alta cardinalidade (cidades, CEPs). LightGBM e CatBoost lidam nativamente com categóricas — não one-hot encode antes.

📏
Scaling numérico

Modelos lineares e redes neurais EXIGEM scaling (StandardScaler, MinMaxScaler). Tree-based (XGBoost, RF) são invariantes a scaling — ignore para eles.

📅
Datetime features

Extraia dia da semana, hora, mês, é-feriado, dias-desde-último-evento. Cyclical encoding (sin/cos) para hora e dia da semana captura ciclicidade.

🎯
Target encoding (com cuidado)

Substitua categoria pela média do target naquela categoria. CUIDADO: aplicar target encoding antes do split causa data leakage. Use sklearn TargetEncoder com CV interno.

Algoritmos: por que XGBoost domina tabular

Em 2026, três famílias de algoritmos cobrem 95% dos problemas tabulares em produção:

📈
Modelos lineares (Logistic / Ridge / Lasso)

Quando você precisa de máxima interpretabilidade (regulação) ou quando o sinal é genuinamente linear. Use Lasso para feature selection automática. Veloz, simples, auditável.

🌲
Random Forest

Bootstrap + agregação de árvores. Robusto, paraleliza bem, baseline forte. Perde para gradient boosting em quase todos os benchmarks reais — use como sanity check, não como modelo final.

Gradient Boosted Trees (XGBoost / LightGBM / CatBoost)

O state-of-the-art em tabular. Treina árvores sequencialmente, cada uma corrigindo erro da anterior. XGBoost = padrão de mercado. LightGBM = mais rápido em datasets grandes (>1M linhas). CatBoost = melhor com muitas categóricas.

🧠
Tabular Neural Networks (TabNet, FT-Transformer)

Promissores em datasets enormes (>10M linhas) com features mistas. Em datasets médios (10k–1M), gradient boosting ainda vence. Considere apenas se você tiver razão clara para preferir NN.

Por que XGBoost é tão bom em dados tabulares

🔬
A receita técnica do XGBoost

Gradient boosting — cada árvore aprende a reduzir o gradiente do erro residual da anterior, otimizando a loss diretamente.
Regularização L1 + L2 nos pesos das folhas — controla overfitting de forma explícita (parâmetros reg_alpha e reg_lambda).
Tratamento nativo de missing — aprende a melhor direção para mandar valores ausentes em cada split.
Histogram-based splitting — bina features contínuas em ~256 valores, acelera training em ordens de magnitude.
Early stopping com validation set — para automaticamente quando para de melhorar.

Validação: como NÃO mentir para si mesmo

Erros de validação são responsáveis por mais modelos quebrados em produção que qualquer outro fator. Three rules:

┌─────────────────────────────────────────────────────────────────────┐ │ ESTRATÉGIAS DE VALIDAÇÃO │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ 1) Hold-out split (train/val/test) │ │ ├── 60% train / 20% val / 20% test │ │ └── Adequado quando: dataset grande (>100k), iid │ │ │ │ 2) K-fold Cross-Validation (k=5 ou k=10) │ │ ├── Treina k vezes, valida no fold deixado de fora │ │ └── Adequado quando: dataset pequeno-médio, sem temporalidade │ │ │ │ 3) Time-series split (CRUCIAL para dados temporais) │ │ ├── Treina em [T0...Tn], valida em [Tn+1...Tn+k] │ │ ├── NUNCA shuffle dados temporais — vaza futuro pro passado │ │ └── Adequado quando: churn, fraude, demand forecasting │ │ │ │ ⚠️ NUNCA use random split em problemas temporais — você terá │ │ AUC de 0.95 em validação e 0.65 em produção. │ └─────────────────────────────────────────────────────────────────────┘

Métricas: além da acurácia

Acurácia é uma métrica enganosa em datasets desbalanceados (fraude tem 0.5% positivos — um modelo que sempre prevê "não fraude" tem 99.5% acurácia e é inútil). Use:

Métrica O que mede Quando usar
AUC-ROC Capacidade de ranquear positivo > negativo Default para classificação binária balanceada
PR-AUC Precision-Recall — foca em positivos Datasets desbalanceados (fraude, churn raro)
F1-score Média harmônica de precision e recall Quando ambos os erros (FP e FN) custam similar
Log Loss / Brier Calibração de probabilidades Quando você usa o score como input para decisão econômica
RMSE / MAE Erro de regressão Regressão. RMSE pune outliers, MAE é robusto.
NDCG@k Qualidade de ranking nos top-k Search, recomendação, learning-to-rank
Python
Pipeline sklearn com Cross-Validation correta
"""
Pipeline sklearn que evita data leakage em features de scaling/encoding.
Cross-Validation estratificada — preserva proporção de classes nos folds.
"""
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, cross_val_score
import pandas as pd
import numpy as np

# Suponha que df tem features 'idade', 'salario' (numéricas), 'cidade', 'plano' (cat)
df = pd.read_csv("clientes.csv")
X = df.drop(columns=["churn"])
y = df["churn"]

num_features = ["idade", "salario", "tempo_casa_meses"]
cat_features = ["cidade", "plano", "canal_aquisicao"]

# Preprocessing por tipo de feature — fit feito DENTRO do CV, não antes
preprocessor = ColumnTransformer([
    ("num", StandardScaler(), num_features),
    ("cat", OneHotEncoder(handle_unknown="ignore", sparse_output=False), cat_features),
])

# Pipeline garante que scaling/encoding é fitado SÓ no train de cada fold
pipe = Pipeline([
    ("prep", preprocessor),
    ("clf", LogisticRegression(max_iter=1000, class_weight="balanced", C=1.0)),
])

# Stratified K-Fold preserva proporção de churn em cada fold
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(pipe, X, y, scoring="roc_auc", cv=cv, n_jobs=-1)

print(f"AUC por fold: {scores}")
print(f"AUC média: {scores.mean():.4f} ± {scores.std():.4f}")
# → AUC média: 0.7842 ± 0.0123  (modelo decente; XGBoost geralmente atinge 0.85+)
4

End-to-End — Churn Prediction com XGBoost

Agora aplicamos os conceitos em um caso real e completo: prever churn de clientes de telecom. Este é provavelmente o caso de ML clássico mais comum em produção em 2026 — toda telecom, banco e SaaS B2B tem um modelo de churn rodando.

Setup do problema

Dataset fictício de telecom com ~10.000 clientes. Target binário churn (1 = cancelou nos últimos 30 dias, 0 = ativo). Features: dados demográficos, plano, consumo, atendimento, pagamento. Objetivo: prever quem vai churnar nos próximos 30 dias para oferecer retenção proativa.

Python
churn_pipeline.py — pipeline completo de XGBoost
"""
End-to-end churn prediction com XGBoost.
Inclui: load → EDA → feature engineering → train → evaluate → save.
"""
import pandas as pd
import numpy as np
import xgboost as xgb
import pickle
from sklearn.model_selection import train_test_split
from sklearn.metrics import (
    roc_auc_score, f1_score, classification_report,
    confusion_matrix, precision_recall_curve
)
from sklearn.preprocessing import LabelEncoder

# ─── 1) Carregar dados ─────────────────────────────────────────────────────
df = pd.read_csv("telecom_churn.csv")
print(f"📂 Dataset: {df.shape[0]:,} linhas × {df.shape[1]} colunas")
print(f"🎯 Taxa de churn: {df['churn'].mean()*100:.2f}%")  # esperado ~14%

# ─── 2) EDA básico — sanity checks que evitam dor depois ───────────────────
print("\n📊 Tipos de dados:")
print(df.dtypes.value_counts())

print("\n🔍 Valores ausentes (top 5):")
print(df.isnull().sum().sort_values(ascending=False).head())

print("\n📈 Estatísticas das numéricas:")
print(df.describe(include=[np.number]).T[['mean', 'std', 'min', 'max']])

# Detectar colunas com alta cardinalidade que podem ser problemáticas
for col in df.select_dtypes(include='object').columns:
    n_unique = df[col].nunique()
    if n_unique > 50:
        print(f"⚠️  {col} tem {n_unique} valores únicos — considerar target encoding")

# ─── 3) Feature Engineering ────────────────────────────────────────────────
# Datetime: dias desde último contato → numérico
df['data_ultimo_contato'] = pd.to_datetime(df['data_ultimo_contato'])
df['dias_desde_contato'] = (pd.Timestamp.now() - df['data_ultimo_contato']).dt.days
df = df.drop(columns=['data_ultimo_contato'])

# Razões úteis (feature engineering manual ainda bate AutoML em domínio conhecido)
df['gasto_por_minuto'] = df['valor_fatura_brl'] / (df['minutos_uso_mes'] + 1)
df['chamados_por_mes'] = df['total_chamados_suporte'] / (df['tempo_casa_meses'] + 1)
df['tem_atraso'] = (df['dias_atraso_pagamento'] > 0).astype(int)

# Categóricas: encoding (XGBoost >=1.6 lida com cat nativamente, mas LabelEncoder é simples)
cat_cols = ['plano', 'cidade', 'canal_aquisicao', 'forma_pagamento']
encoders = {}
for col in cat_cols:
    le = LabelEncoder()
    df[col] = le.fit_transform(df[col].astype(str))
    encoders[col] = le

# ─── 4) Split temporal (NÃO shuffle — churn é temporal) ────────────────────
df = df.sort_values('mes_referencia')
split_idx = int(len(df) * 0.8)
df_train = df.iloc[:split_idx]
df_test = df.iloc[split_idx:]

X_train = df_train.drop(columns=['churn', 'cliente_id', 'mes_referencia'])
y_train = df_train['churn']
X_test = df_test.drop(columns=['churn', 'cliente_id', 'mes_referencia'])
y_test = df_test['churn']

print(f"\n🔧 Train: {len(X_train):,} | Test: {len(X_test):,}")
print(f"   Churn rate train: {y_train.mean()*100:.2f}% | test: {y_test.mean()*100:.2f}%")

# ─── 5) Treinar XGBoost com early stopping ────────────────────────────────
# scale_pos_weight = ratio neg/pos — compensa desbalanceamento
scale_pos = (y_train == 0).sum() / (y_train == 1).sum()

modelo = xgb.XGBClassifier(
    n_estimators=500,
    max_depth=6,
    learning_rate=0.05,
    subsample=0.8,
    colsample_bytree=0.8,
    reg_alpha=0.1,           # L1
    reg_lambda=1.0,          # L2
    scale_pos_weight=scale_pos,
    eval_metric='auc',
    early_stopping_rounds=30,
    tree_method='hist',      # mais rápido que 'exact'
    random_state=42,
    n_jobs=-1,
)

# Holdout interno para early stopping (não vaza o test set)
X_tr, X_val, y_tr, y_val = train_test_split(
    X_train, y_train, test_size=0.15, stratify=y_train, random_state=42
)
modelo.fit(X_tr, y_tr, eval_set=[(X_val, y_val)], verbose=False)
print(f"\n✓ Treinado. Best iteration: {modelo.best_iteration}")

# ─── 6) Avaliar no test set ────────────────────────────────────────────────
proba_test = modelo.predict_proba(X_test)[:, 1]
pred_test = (proba_test >= 0.5).astype(int)

auc = roc_auc_score(y_test, proba_test)
f1 = f1_score(y_test, pred_test)

print(f"\n📊 Performance no test set:")
print(f"   AUC: {auc:.4f}")
print(f"   F1:  {f1:.4f}")
print(f"\n{classification_report(y_test, pred_test, target_names=['ativo', 'churn'])}")
print(f"Confusion Matrix:\n{confusion_matrix(y_test, pred_test)}")

# Top-15 features mais importantes (gain)
importance = pd.DataFrame({
    'feature': X_train.columns,
    'gain': modelo.feature_importances_
}).sort_values('gain', ascending=False).head(15)
print(f"\n🎯 Top-15 features por gain:\n{importance.to_string(index=False)}")

# ─── 7) Salvar modelo + artefatos para produção ───────────────────────────
artefato = {
    'modelo': modelo,
    'encoders': encoders,
    'feature_order': list(X_train.columns),
    'metadata': {
        'auc_test': auc,
        'f1_test': f1,
        'train_size': len(X_train),
        'churn_rate_train': float(y_train.mean()),
        'data_treino': pd.Timestamp.now().isoformat(),
        'xgboost_version': xgb.__version__,
    }
}

with open('churn_model_v1.pkl', 'wb') as f:
    pickle.dump(artefato, f)

print("\n💾 Modelo salvo em churn_model_v1.pkl")
print(f"   Tamanho: ~{round(len(pickle.dumps(artefato))/1024, 1)} KB")
Output esperado
📂 Dataset: 10,000 linhas × 18 colunas
🎯 Taxa de churn: 14.32%

📊 Tipos de dados:
float64    8
int64      6
object     4

🔍 Valores ausentes (top 5):
dias_atraso_pagamento    342
total_chamados_suporte   128
...

⚠️  cidade tem 287 valores únicos — considerar target encoding

🔧 Train: 8,000 | Test: 2,000
   Churn rate train: 14.18% | test: 14.85%

✓ Treinado. Best iteration: 187

📊 Performance no test set:
   AUC: 0.8734
   F1:  0.6421

              precision    recall  f1-score   support
       ativo       0.94      0.91      0.93      1703
       churn       0.55      0.66      0.60       297

Confusion Matrix:
[[1551  152]
 [ 101  196]]

🎯 Top-15 features por gain:
                feature      gain
   total_chamados_suporte   0.1842
       dias_desde_contato   0.1311
              tem_atraso   0.0987
        gasto_por_minuto   0.0823
       chamados_por_mes   0.0712
        valor_fatura_brl   0.0654
       tempo_casa_meses   0.0589
                   plano   0.0521
   ...

💾 Modelo salvo em churn_model_v1.pkl
   Tamanho: ~487.3 KB

# AUC 0.87 — bom para churn de telecom (benchmark típico: 0.82-0.89).
# Recall de 0.66 nos churners significa: capturamos 66% dos clientes
# que iam cancelar. Com lista de retenção, isso vira ROI direto.
💡
Próximos passos em produção

O modelo treinado é apenas 30% do trabalho. Em produção você precisa: (1) registrar o modelo no MLflow com hash do dataset; (2) servir via FastAPI ou BentoML com cache de features; (3) monitorar data drift (PSI, KS-test) semanalmente; (4) definir gatilho de retreino (drift > 0.2 ou queda de AUC > 5pp); (5) conectar a um sistema de campanha de retenção que mede ROI real (uplift modeling). É exatamente isso que veremos na próxima seção.

5

MLOps Clássico (vs LLMOps)

MLOps clássico é uma disciplina madura — surgiu em ~2018 e tem ferramentas estáveis em produção há anos. LLMOps é diferente: lida com não-determinismo, prompts versionados, eval com LLM-as-judge e custo dominado por tokens. Em 2026 a maioria das empresas operam os dois stacks lado a lado.

Diferenças entre MLOps e LLMOps

Aspecto MLOps Clássico LLMOps
Artefato versionado Modelo binário (.pkl, .onnx, .pmml) Prompt + tools + system message
Ciclo de retreino Semanas/meses, sob drift Iteração de prompts em horas
Avaliação Métricas determinísticas (AUC, F1, RMSE) LLM-as-judge, human eval, ragas
Custo dominante GPU de treino (one-shot) Tokens em produção (recorrente)
Determinismo 100% determinístico Não-determinístico mesmo com temp=0
Drift Data drift + concept drift Prompt drift + comportamento de modelo
Stack canônico MLflow, DVC, Feast, Sagemaker LangSmith, Helicone, Langfuse, Braintrust

Stack canônico de MLOps

📦
MLflow — tracking + registry

Padrão de mercado. Registra hiperparâmetros, métricas, artefatos, versão de dados. Model Registry centraliza staging → production. Funciona local, no Databricks ou self-hosted.

🗂️
DVC — versionar dados

Git para datasets. Salva ponteiros leves no git e dados pesados em S3/GCS. Crítico para reprodutibilidade — quando o modelo dá problema 6 meses depois, você precisa do dataset exato.

🏪
Feature Store (Feast / Tecton / Hopsworks)

Repositório central de features com paridade train/serve. Resolve o pior problema de ML: features calculadas no batch são diferentes do que chega ao modelo em real-time. Crucial em fintechs.

📡
Monitoring de drift

Evidently, Whylabs, Arize. Alertam quando distribuição das features muda (PSI > 0.2) ou quando label distribution muda (concept drift). Trigger automático de retreino.

🔄
CI/CD de modelos

GitHub Actions / GitLab CI rodam treino + eval + deploy ao push. Sagemaker Pipelines, Vertex AI Pipelines, Databricks Workflows orquestram em produção.

🚀
Serving

BentoML, KServe, Sagemaker Endpoints, Vertex Predictions. ONNX Runtime para max performance em CPU. Triton para serving multi-modelo otimizado em GPU.

Tipos de drift e como detectar

Modelo em produção vai degradar — a única dúvida é quanto e quando. Existem dois tipos fundamentais de drift, com causas e tratamentos diferentes:

┌─────────────────────────────────────────────────────────────────────────┐ │ TIPOS DE DRIFT │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ DATA DRIFT (covariate shift) │ │ Mudança em P(X) — distribuição das features muda │ │ Exemplo: COVID muda padrão de uso de cartão; modelo continua válido │ │ mas vê dados nunca antes vistos. │ │ Detectar: PSI (Population Stability Index), KS-test, Wasserstein │ │ Threshold prático: PSI > 0.2 → investigar; > 0.25 → retreinar │ │ │ │ CONCEPT DRIFT │ │ Mudança em P(Y|X) — relação feature→target muda │ │ Exemplo: regulação muda perfil de fraude; mesma feature agora │ │ tem significado diferente para o target. │ │ Detectar: queda de AUC/F1 em janela móvel, monitor de prediction │ │ vs ground-truth (delayed label). │ │ Tratar: retreinar urgentemente; pode exigir feature engineering novo │ │ │ │ PREDICTION DRIFT (saídas do modelo) │ │ Mudança em P(Ŷ) — distribuição das predições muda │ │ Útil quando ground-truth é delayed (churn só conhecido em 30 dias). │ │ Se Ŷ muda mas não houve mudança esperada de negócio → bug ou drift. │ │ │ └─────────────────────────────────────────────────────────────────────────┘

CI/CD para modelos: GitHub Actions + DVC + MLflow

Python
Tracking experimentos com MLflow + promoção condicional
"""
Treina modelo, registra no MLflow e promove para 'Production' apenas
se AUC > baseline atual. Padrão usado em pipelines GitHub Actions.
"""
import mlflow
import mlflow.xgboost
import xgboost as xgb
from mlflow.tracking import MlflowClient
from sklearn.metrics import roc_auc_score, f1_score

mlflow.set_tracking_uri("http://mlflow.empresa.com:5000")
mlflow.set_experiment("churn-telecom")

MODEL_NAME = "churn_telecom"

def treinar_e_registrar(X_train, y_train, X_test, y_test, params: dict):
    with mlflow.start_run() as run:
        # Log params automaticamente
        mlflow.log_params(params)
        mlflow.log_param("dataset_hash", "sha256:abc123...")  # de DVC
        mlflow.log_param("commit_sha", "git rev-parse HEAD")

        # Treina
        model = xgb.XGBClassifier(**params, eval_metric='auc', random_state=42)
        model.fit(X_train, y_train)

        # Avalia
        proba = model.predict_proba(X_test)[:, 1]
        pred = (proba >= 0.5).astype(int)
        auc = roc_auc_score(y_test, proba)
        f1 = f1_score(y_test, pred)

        mlflow.log_metric("auc_test", auc)
        mlflow.log_metric("f1_test", f1)

        # Salva modelo no Model Registry
        mlflow.xgboost.log_model(
            model,
            artifact_path="model",
            registered_model_name=MODEL_NAME,
        )
        return run.info.run_id, auc

def promover_se_melhor(run_id: str, auc_novo: float, threshold: float = 0.005):
    """
    Promove para Production apenas se ganho > threshold sobre o atual.
    Evita 'noise promotions' — pequenas variações de CV.
    """
    client = MlflowClient()
    versoes = client.get_latest_versions(MODEL_NAME, stages=["Production"])

    if versoes:
        prod_run = client.get_run(versoes[0].run_id)
        auc_prod = prod_run.data.metrics["auc_test"]
        ganho = auc_novo - auc_prod
        print(f"AUC atual em prod: {auc_prod:.4f} | AUC novo: {auc_novo:.4f} | Ganho: {ganho:+.4f}")

        if ganho < threshold:
            print(f"❌ Ganho < {threshold} — NÃO promovendo. Mantendo modelo atual em prod.")
            return False

    # Encontra a versão recém-registrada
    nova_versao = client.search_model_versions(f"run_id='{run_id}'")[0]

    # Move atual para Archived, novo para Production
    if versoes:
        client.transition_model_version_stage(
            name=MODEL_NAME, version=versoes[0].version, stage="Archived"
        )
    client.transition_model_version_stage(
        name=MODEL_NAME, version=nova_versao.version, stage="Production"
    )
    print(f"✅ Modelo v{nova_versao.version} promovido para Production")
    return True

# Uso típico em CI:
params = {"n_estimators": 500, "max_depth": 6, "learning_rate": 0.05}
run_id, auc = treinar_e_registrar(X_train, y_train, X_test, y_test, params)
promover_se_melhor(run_id, auc)
6

Combinando ML Clássico + LLMs (padrão híbrido)

O padrão dominante em 2026 não é "ML clássico OU LLM" — é ambos, em camadas. Empresas que extraem o máximo de IA não escolhem entre stacks; combinam o determinismo e baixo custo do ML clássico com a flexibilidade dos LLMs apenas onde faz sentido. Existem três padrões arquiteturais consolidados.

Padrão 1 — LLM como feature extractor

Use embeddings de texto (text-embedding-3-large, BGE, voyage-3) para transformar campos textuais em vetores numéricos. Esses vetores entram como features adicionais em um modelo tabular tradicional. Combina o melhor dos dois mundos: compreensão semântica do LLM + velocidade e custo do XGBoost.

┌─────────────────────────────────────────────────────────────────────┐ │ PIPELINE HÍBRIDO 1 — LLM EMBEDDINGS + XGBOOST │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ Cliente │ │ ├── features tabulares (idade, renda, plano, ...) │ │ │ │ │ └── descrição livre (texto: motivo de cancelamento) │ │ │ │ │ ▼ │ │ [LLM Embedding] → vetor 1536d │ │ │ │ │ ▼ │ │ [PCA / UMAP para 32d] (opcional, reduz dim) │ │ │ │ │ ▼ │ │ features tabulares + 32 features de embedding │ │ │ │ │ ▼ │ │ [XGBoost] → P(churn) │ │ │ │ ✓ Embedding calculado uma vez (cacheável) │ │ ✓ Inferência final = XGBoost (sub-ms, deterministica) │ └─────────────────────────────────────────────────────────────────────┘

Padrão 2 — LLM como router, ML como executor

Em sistemas com múltiplos modelos especializados, um LLM rápido (Haiku, gpt-4o-mini) atua como classificador de intenção e router. Decide qual modelo tabular chamar para cada tipo de pedido. Usado em: atendimento ao cliente bancário, triagem médica, sistemas de suporte multi-domínio.

Padrão 3 — Embeddings + features no mesmo modelo

Caso de uso clássico: classificação de tickets de suporte. Você tem o texto do ticket (que pode ser embedado) + features estruturadas (cliente, plano, histórico). Concatene tudo em um único vetor e treine XGBoost.

Python
Híbrido: OpenAI embeddings + features tabulares + XGBoost
"""
Classificação de prioridade de ticket de suporte.
Combina: texto do ticket (via OpenAI embeddings) + features de cliente.
Resultado: XGBoost prevê P(prioridade=alta).
"""
import numpy as np
import pandas as pd
import xgboost as xgb
from openai import OpenAI
from sklearn.decomposition import PCA
from sklearn.model_selection import train_test_split
from sklearn.metrics import roc_auc_score, classification_report

client = OpenAI()  # OPENAI_API_KEY no env

# ─── 1) Carregar dataset de tickets ────────────────────────────────────────
df = pd.read_csv("tickets_suporte.csv")
# Colunas: ticket_id, texto, cliente_id, plano, tempo_casa_meses,
#         tickets_abertos_30d, prioridade_alta (target)

# ─── 2) Calcular embeddings em batch (cache em produção) ──────────────────
def embed_batch(textos: list[str], model: str = "text-embedding-3-small") -> np.ndarray:
    """Embeddings da OpenAI em batch — 1536 dimensões."""
    resp = client.embeddings.create(input=textos, model=model)
    return np.array([d.embedding for d in resp.data])

# Em produção: pré-calcule e cache em vector DB (Pinecone, pgvector)
print("📡 Gerando embeddings...")
batch_size = 100
embeddings = []
for i in range(0, len(df), batch_size):
    batch = df["texto"].iloc[i:i+batch_size].tolist()
    embeddings.append(embed_batch(batch))
emb_full = np.vstack(embeddings)
print(f"✓ Shape dos embeddings: {emb_full.shape}")  # (N, 1536)

# ─── 3) Reduzir dimensionalidade (1536 → 32) com PCA ──────────────────────
# Por que? XGBoost com 1536 dims sofre da maldição de dimensionalidade.
# 32 dims preservam ~85% da variância para domínio textual fechado.
pca = PCA(n_components=32, random_state=42)
emb_reduced = pca.fit_transform(emb_full)
print(f"✓ Variância preservada com 32d: {pca.explained_variance_ratio_.sum():.2%}")

# ─── 4) Concatenar com features tabulares ─────────────────────────────────
features_tab = df[["plano", "tempo_casa_meses", "tickets_abertos_30d"]].values
features_tab[:, 0] = pd.factorize(features_tab[:, 0])[0]  # encode plano

X = np.hstack([features_tab, emb_reduced])  # (N, 3 + 32) = (N, 35)
y = df["prioridade_alta"].values

# ─── 5) Train + Eval ──────────────────────────────────────────────────────
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2, stratify=y, random_state=42)

modelo = xgb.XGBClassifier(
    n_estimators=300, max_depth=5, learning_rate=0.05,
    eval_metric='auc', random_state=42,
)
modelo.fit(X_tr, y_tr)
auc = roc_auc_score(y_te, modelo.predict_proba(X_te)[:, 1])
print(f"\n📊 AUC (híbrido emb+tab): {auc:.4f}")

# Comparação: só features tabulares (sem embedding)
modelo_tab = xgb.XGBClassifier(n_estimators=300, max_depth=5, random_state=42)
modelo_tab.fit(X_tr[:, :3], y_tr)
auc_tab = roc_auc_score(y_te, modelo_tab.predict_proba(X_te[:, :3])[:, 1])
print(f"📊 AUC (só tabular):     {auc_tab:.4f}")
print(f"📈 Ganho do embedding:    {auc - auc_tab:+.4f}")

# Em produção, esperado: ganho de 0.05 a 0.12 em AUC adicionando texto.
Output esperado
📡 Gerando embeddings...
✓ Shape dos embeddings: (4500, 1536)
✓ Variância preservada com 32d: 84.71%

📊 AUC (híbrido emb+tab): 0.8923
📊 AUC (só tabular):     0.7841
📈 Ganho do embedding:    +0.1082

# Ganho de 10pp em AUC adicionando o texto do ticket via embedding.
# Custo: 1 chamada à API de embeddings por ticket (~$0.00002).
# Latência: 50ms p/ embed + 1ms p/ XGBoost = 51ms total.
# vs. classificar com gpt-4o direto: 800ms+ e custo 100x maior.

Casos práticos brasileiros

💜
Nubank

Embeddings de descrição de transação alimentam modelos de fraude e categorização. LLM extrai sinal semântico ("uber - 12345" e "uber - 67890" devem ser próximos), XGBoost decide.

🛒
Mercado Livre

Ranking de busca usa Learning-to-Rank (LambdaMART) com features clássicas + embeddings de query e título. LLM puro seria caro demais para 100M+ buscas/dia.

🍔
iFood

Modelos de tempo de entrega combinam features tabulares (distância, hora, restaurante) com embeddings de notas de pedido para detectar pedidos atípicos. ETA com erro <3min.

🏦
Itaú / BTG

Análise de relatórios financeiros: embeddings BGE-pt extraem sinal de earnings calls, viram features em modelos de pricing e análise de risco que rodam em modelos clássicos auditáveis.

🎯
Quando NÃO usar híbrido

O padrão híbrido só faz sentido quando o texto carrega sinal real para o problema. Se você está classificando algo com features tabulares fortes (idade, renda, score de crédito) e o texto é apenas ruído, adicionar embeddings só aumenta custo sem ganho. Sempre meça o ganho de AUC contra o custo adicional — se < 2pp de AUC, provavelmente não vale.

Resumo executivo da disciplina

ML Clássico não é legacy — é o backbone da maior parte das decisões de IA em produção. LLMs trouxeram capacidades novas e revolucionárias, mas não substituem a regressão logística que aprova seu cartão de crédito, o XGBoost que prevê seu churn ou o LightGBM que ranqueia os resultados da sua busca. O arquiteto de IA sênior em 2026 domina os dois stacks e sabe quando usar cada um — e cada vez mais, como combiná-los em arquiteturas híbridas que são ao mesmo tempo poderosas, baratas e auditáveis.