Data Science e ML Clássico
LLMs não substituem regressão, classificação e ML supervisionado — eles complementam. A maior parte do ML em produção em 2026 ainda é XGBoost, LightGBM e modelos lineares. Esta disciplina cobre o stack clássico que continua dominando ranking, fraude, churn, recomendação e categorização tabular.
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
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.
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.
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.
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:
10M inferências @ R$0.0001
10M inferências @ R$0.05
Por decisão equivalente
Escolhendo ML clássico
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.
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:
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.
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.
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.
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
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
"""
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.")
📐 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)
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.
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.
Modelos lineares e redes neurais EXIGEM scaling (StandardScaler, MinMaxScaler). Tree-based (XGBoost, RF) são invariantes a scaling — ignore para eles.
Extraia dia da semana, hora, mês, é-feriado, dias-desde-último-evento. Cyclical encoding (sin/cos) para hora e dia da semana captura ciclicidade.
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:
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.
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.
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.
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
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:
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 |
"""
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+)
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.
"""
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")
📂 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.
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.
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
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.
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.
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.
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.
GitHub Actions / GitLab CI rodam treino + eval + deploy ao push. Sagemaker Pipelines, Vertex AI Pipelines, Databricks Workflows orquestram em produção.
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:
CI/CD para modelos: GitHub Actions + DVC + MLflow
"""
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)
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.
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.
"""
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.
📡 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
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.
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.
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.
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.
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.