AUC Boa, F1 Zero: a Armadilha do Threshold Padrão
O capítulo 3 definiu as features de cada um dos quatro cenários. Agora é a hora de treinar os modelos de verdade, comparar contra baseline, e registrar cada tentativa com MLflow (parâmetro e métrica de cada run, só local, mlruns/, sem subir pra lugar nenhum, é boa prática de rastreabilidade, não vira gráfico aqui no blog). Notebook completo, executado, público no Colab. Esse notebook também parte do zero (recarrega os 9 CSVs, remonta o dataframe mestre) e reconstrói as features do capítulo 3:
geo_media = geolocation.groupby('geolocation_zip_code_prefix')[['geolocation_lat', 'geolocation_lng']].mean().reset_index()
def haversine(lat1, lon1, lat2, lon2):
R = 6371
phi1, phi2 = np.radians(lat1), np.radians(lat2)
dphi = np.radians(lat2 - lat1)
dlambda = np.radians(lon2 - lon1)
a = np.sin(dphi / 2) ** 2 + np.cos(phi1) * np.cos(phi2) * np.sin(dlambda / 2) ** 2
return 2 * R * np.arcsin(np.sqrt(a))
m = mestre.copy()
m['customer_zip_code_prefix'] = m['customer_zip_code_prefix'].astype('int64')
m['seller_zip_code_prefix'] = m['seller_zip_code_prefix'].astype('int64')
geo_media['geolocation_zip_code_prefix'] = geo_media['geolocation_zip_code_prefix'].astype('int64')
m = m.merge(geo_media.rename(columns={'geolocation_zip_code_prefix': 'customer_zip_code_prefix', 'geolocation_lat': 'cust_lat', 'geolocation_lng': 'cust_lng'}), on='customer_zip_code_prefix', how='left')
m = m.merge(geo_media.rename(columns={'geolocation_zip_code_prefix': 'seller_zip_code_prefix', 'geolocation_lat': 'sell_lat', 'geolocation_lng': 'sell_lng'}), on='seller_zip_code_prefix', how='left')
m['distance_km'] = haversine(m['cust_lat'], m['cust_lng'], m['sell_lat'], m['sell_lng'])
m['approval_hours'] = (m['order_approved_at'] - m['order_purchase_timestamp']).dt.total_seconds() / 3600
m['month'] = m['order_purchase_timestamp'].dt.month
m['atraso_dias'] = (m['order_delivered_customer_date'] - m['order_estimated_delivery_date']).dt.days
m['delivery_days'] = (m['order_delivered_customer_date'] - m['order_purchase_timestamp']).dt.days
Mesmo código de sempre: distance_km (Haversine, com o fix de int64 do capítulo 3), approval_hours, month, atraso_dias e delivery_days, tudo já explicado com calma por lá.
Cenário 1: atraso na entrega, e a armadilha do threshold padrão
Baseline (sempre chuta "no prazo") → Regressão Logística → Random Forest → XGBoost, nas features honestas do capítulo 3:
delay_df = (
m.drop_duplicates(subset='order_id')
[['order_id', 'distance_km', 'product_weight_g', 'price', 'freight_value', 'approval_hours', 'month', 'atraso_dias']]
.dropna()
)
delay_df['atrasado'] = (delay_df['atraso_dias'] > 0).astype(int)
features_1 = ['distance_km', 'product_weight_g', 'price', 'freight_value', 'approval_hours', 'month']
X1, y1 = delay_df[features_1], delay_df['atrasado']
X1tr, X1te, y1tr, y1te = train_test_split(X1, y1, test_size=0.2, random_state=42, stratify=y1)
modelos_1 = {
'Baseline': DummyClassifier(strategy='most_frequent'),
'Logistic Regression': LogisticRegression(max_iter=1000),
'Random Forest': RandomForestClassifier(n_estimators=100, max_depth=8, random_state=42),
'XGBoost': xgb.XGBClassifier(n_estimators=100, max_depth=6, random_state=42, eval_metric='logloss'),
}
resultados_1 = []
for nome, modelo in modelos_1.items():
with mlflow.start_run(run_name=f"atraso_{nome}"):
modelo.fit(X1tr, y1tr)
proba = modelo.predict_proba(X1te)[:, 1]
pred = modelo.predict(X1te)
auc = roc_auc_score(y1te, proba)
f1 = f1_score(y1te, pred)
prec = precision_score(y1te, pred, zero_division=0)
rec = recall_score(y1te, pred)
mlflow.log_params({'modelo': nome, 'cenario': 'atraso'})
mlflow.log_metrics({'auc': auc, 'f1': f1, 'precision': prec, 'recall': rec})
resultados_1.append({'model': nome, 'value': round(float(auc), 4)})
DummyClassifier(strategy='most_frequent') é o baseline mais honesto que existe: sempre chuta a classe majoritária ("no prazo"), sem olhar feature nenhuma, qualquer modelo de verdade precisa bater isso pra justificar a própria existência (95.968 pedidos usados, taxa real de atraso de 6,76%). Os quatro modelos entram num dicionário nome → instância, e o for treina, prevê e mede os quatro exatamente do mesmo jeito, dentro de um bloco with mlflow.start_run(run_name=...): cada iteração vira uma "run" registrada, mlflow.log_params guarda qual modelo e qual cenário geraram aquele resultado, mlflow.log_metrics guarda as quatro métricas, tudo gravado localmente em mlruns/, consultável depois (mlflow ui) sem precisar recalcular nada. predict_proba(...)[:, 1] pega a probabilidade contínua da classe "atrasou" (pra AUC), predict(...) já devolve a decisão binária limiarizada em 0,5 por padrão (pra F1/precisão/revocação), a mesma distinção score-contínuo-vs-decisão-binária que já apareceu no projeto de detecção de intrusão.
| Modelo | AUC | F1 | Precisão | Revocação |
|---|---|---|---|---|
| Baseline | 0,5000 | 0,0000 | 0,0000 | 0,0000 |
| Regressão Logística | 0,6064 | 0,0031 | 1,0000 | 0,0015 |
| Random Forest | 0,7388 | 0,0000 | 0,0000 | 0,0000 |
| XGBoost | 0,7261 | 0,0268 | 0,4000 | 0,0139 |
Carregando dados reais...
Repara numa coisa estranha nessa tabela: o Random Forest tem a melhor AUC de todo mundo (0,7388), mas F1, precisão e revocação zerados. Não é bug, é a mecânica da coisa. AUC mede se o modelo consegue ranquear melhor os casos arriscados que os seguros, comparando pares de exemplo sem nunca decidir nada, e nisso o Random Forest manda bem. F1, precisão e revocação, em compensação, dependem de uma decisão binária: acima de 0,5 de probabilidade o modelo grita "vai atrasar!", abaixo ele fica calado. Com só 6,76% dos pedidos atrasando de verdade, o Random Forest aprendeu tão bem a reconhecer a classe majoritária que nunca cruza 0,5 de probabilidade pra nenhum pedido, nem pros que realmente atrasam. Ele sabe ranquear risco, só nunca "aposta" no positivo.
Precisão aqui é: dos pedidos que o modelo marcou como "vai atrasar", quantos atrasaram de verdade. Revocação é o oposto: dos pedidos que atrasaram de verdade, quantos o modelo pegou. F1 é a média harmônica dos dois, um jeito de resumir "o modelo é bom nas duas pontas" num só número. É a mesma tríade que eu já tinha usado lá na playlist Pattern Recognition, na detecção de fraude em cartão de crédito, outro problema de classe super desbalanceada, e o sintoma é parecido: métrica de ranking (AUC) parece ótima, métrica de decisão (F1) desmascara que o modelo, do jeito que está, não serve pra decidir nada sozinho.
O XGBoost, apesar da AUC um pouco menor (0,7261), é o único que realmente "aposta": F1 0,0268, precisão 0,40 (quando ele marca "vai atrasar", acerta em 4 de cada 10 vezes), revocação 0,0139 (mas só pega 1,4% dos atrasos reais). Nenhum dos quatro está pronto pra produção assim, do jeito que está publicado aqui. O próximo passo de verdade seria mexer no threshold de decisão em vez de usar 0,5 cego, ou balancear a classe no treino, mas isso é assunto pra outro dia, esse capítulo é sobre comparar modelo, não sobre ajustar limiar de decisão.
Cenário 2: nota da avaliação (regressão)
Baseline (sempre chuta a média) → Regressão Linear → Random Forest → XGBoost, nas features do capítulo 3:
s2 = (
m.drop_duplicates(subset='order_id')
[['order_id', 'atraso_dias', 'delivery_days', 'price', 'freight_value', 'payment_installments', 'review_score']]
.dropna()
)
features_2 = ['atraso_dias', 'delivery_days', 'price', 'freight_value', 'payment_installments']
X2, y2 = s2[features_2], s2['review_score']
X2tr, X2te, y2tr, y2te = train_test_split(X2, y2, test_size=0.2, random_state=42)
modelos_2 = {
'Baseline': DummyRegressor(strategy='mean'),
'Linear Regression': LinearRegression(),
'Random Forest': RandomForestRegressor(n_estimators=100, max_depth=8, random_state=42),
'XGBoost': xgb.XGBRegressor(n_estimators=100, max_depth=6, random_state=42),
}
resultados_2 = []
for nome, modelo in modelos_2.items():
with mlflow.start_run(run_name=f"nota_{nome}"):
modelo.fit(X2tr, y2tr)
pred = modelo.predict(X2te)
r2 = r2_score(y2te, pred)
mae = mean_absolute_error(y2te, pred)
mlflow.log_params({'modelo': nome, 'cenario': 'nota'})
mlflow.log_metrics({'r2': r2, 'mae': mae})
resultados_2.append({'model': nome, 'value': round(float(r2), 4)})
Mesma estrutura do cenário 1 (dicionário de modelos, for com mlflow.start_run), só que agora é regressão: DummyRegressor(strategy='mean') é o baseline que sempre chuta a média das notas de treino, e as métricas trocam pra R² (quanto da variância da nota o modelo explica) e MAE (erro médio absoluto, em estrelas). Nota de review, de 1 a 5, vira alvo contínuo aqui em vez de categórico, escolha que o capítulo 3 já justificou (95.829 pedidos usados).
| Modelo | R² | MAE |
|---|---|---|
| Baseline | -0,0001 | 0,9950 |
| Regressão Linear | 0,1232 | 0,9228 |
| Random Forest | 0,2028 | 0,8765 |
| XGBoost | 0,1861 | 0,8794 |
Carregando dados reais...
R² mede quanto da variação da nota o modelo explica (1,0 seria previsão perfeita, 0 seria "chutar sempre a média"). MAE é o erro médio em estrelas, quanto o modelo erra pra cima ou pra baixo, na média, comparado com a nota real. O Random Forest ganha aqui, de raspão, do XGBoost (0,2028 contra 0,1861), o oposto do que normalmente se espera desses dois. Mas o dado mais honesto é o teto baixo dos dois: nem o melhor modelo passa de 20% de variância explicada. Isso bate com o que o capítulo 3 já tinha achado usando informação mútua, atraso_dias e delivery_days dominam disparado a nota, e sobra pouca informação nas outras features (preço, frete, parcelas) pra um modelo qualquer explorar. A nota do cliente depende de um monte de coisa que não está nesse dataframe (qualidade real do produto, embalagem, atendimento), não é falta de modelo melhor, é falta de feature.
Cenário 3: valor do frete (regressão)
Baseline → Regressão Linear → Random Forest → XGBoost, nas features físicas do produto mais a distance_km calculada no capítulo 3:
s3 = (
m.drop_duplicates(subset='order_id')
[['order_id', 'freight_value', 'product_weight_g', 'product_length_cm', 'product_height_cm', 'product_width_cm', 'distance_km']]
.dropna()
)
features_3 = ['product_weight_g', 'product_length_cm', 'product_height_cm', 'product_width_cm', 'distance_km']
X3, y3 = s3[features_3], s3['freight_value']
X3tr, X3te, y3tr, y3te = train_test_split(X3, y3, test_size=0.2, random_state=42)
modelos_3 = {
'Baseline': DummyRegressor(strategy='mean'),
'Linear Regression': LinearRegression(),
'Random Forest': RandomForestRegressor(n_estimators=100, max_depth=8, random_state=42),
'XGBoost': xgb.XGBRegressor(n_estimators=100, max_depth=6, random_state=42),
}
resultados_3 = []
for nome, modelo in modelos_3.items():
with mlflow.start_run(run_name=f"frete_{nome}"):
modelo.fit(X3tr, y3tr)
pred = modelo.predict(X3te)
r2 = r2_score(y3te, pred)
mae = mean_absolute_error(y3te, pred)
mlflow.log_params({'modelo': nome, 'cenario': 'frete'})
mlflow.log_metrics({'r2': r2, 'mae': mae})
resultados_3.append({'model': nome, 'value': round(float(r2), 4)})
Mesmo esqueleto de novo (dicionário de modelos, for com mlflow.start_run, R²/MAE), dessa vez com distance_km somada às quatro features físicas do produto que o capítulo 3 já tinha isolado por correlação (98.650 pedidos usados).
| Modelo | R² | MAE |
|---|---|---|
| Baseline | -0,0001 | 8,6057 |
| Regressão Linear | 0,5322 | 5,3800 |
| Random Forest | 0,6178 | 4,5947 |
| XGBoost | 0,6385 | 4,3112 |
Carregando dados reais...
Aqui sim o XGBoost ganha claro dos outros três, R² 0,6385 e erro médio de R$ 4,31. Faz sentido físico: transportadora calcula frete numa fórmula que mistura peso, dimensão e distância de um jeito não-linear (peso volumétrico, faixas de preço por distância), e árvore de decisão (Random Forest, XGBoost) capta esse tipo de não-linearidade muito melhor que uma reta de Regressão Linear, que já fica em segundo lugar mesmo assim (R² 0,53). Comparado ao teto baixo do cenário 2, esse aqui é o cenário onde "mais dado numérico relevante" realmente compra performance de modelo.
Cenário 4: segmentação de clientes, primeira rodada do RFM
Diferente dos três anteriores, aqui não tem "modelo certo" pra comparar, é K-means rodando pra vários valores de K (de 2 a 8) nas três variáveis RFM (recência, frequência, valor monetário) do capítulo 3.
data_max = orders['order_purchase_timestamp'].max()
orders_validos = orders[orders['order_status'] != 'canceled']
oc = orders_validos.merge(customers, on='customer_id', how='left')
pagamento_por_pedido = payments.groupby('order_id')['payment_value'].sum().reset_index()
oc = oc.merge(pagamento_por_pedido, on='order_id', how='left')
rfm = oc.groupby('customer_unique_id').agg(
recencia_dias=('order_purchase_timestamp', lambda x: (data_max - x.max()).days),
frequencia=('order_id', 'nunique'),
valor_monetario=('payment_value', 'sum'),
).reset_index().dropna()
scaler = StandardScaler()
rfm_escalado = scaler.fit_transform(rfm[['recencia_dias', 'frequencia', 'valor_monetario']])
resultados_4 = []
for k in range(2, 9):
with mlflow.start_run(run_name=f"rfm_k{k}"):
kmeans = KMeans(n_clusters=k, random_state=42, n_init=10)
labels = kmeans.fit_predict(rfm_escalado)
sil = silhouette_score(rfm_escalado, labels, sample_size=5000, random_state=42)
mlflow.log_params({'k': k, 'cenario': 'rfm'})
mlflow.log_metrics({'silhouette': sil, 'inertia': kmeans.inertia_})
resultados_4.append({'model': f'k={k}', 'value': round(float(sil), 4)})
Reconstrução do RFM idêntica ao capítulo 3 (data_max, pedidos válidos, recência/frequência/valor). A novidade aqui é o StandardScaler: as três variáveis do RFM vivem em escalas bem diferentes (recência em dias, frequência numa contagem pequena, valor monetário em reais que chegam a milhares), e K-means decide cluster por distância euclidiana, então sem escalonar a variável de maior amplitude numérica dominaria a distância sozinha, o mesmo motivo de normalizar antes de qualquer método baseado em distância que já apareceu lá na playlist Pattern Recognition. O for k in range(2, 9) treina um K-means diferente pra cada K candidato. Pra escolher qual K é melhor, usei uma métrica diferente do método do cotovelo que o professor usou lá na playlist: o coeficiente de silhueta, calculado com silhouette_score(..., sample_size=5000, random_state=42). Ele mede, pra cada cliente, o quão parecido ele é do próprio grupo comparado ao grupo vizinho mais próximo, e a média de todo mundo vira o score do K inteiro, entre -1 (cliente no grupo errado) e 1 (grupos compactos e bem separados). O sample_size=5000 existe porque essa métrica compara toda dupla de ponto entre si, um custo que cresce quadrático com o número de clientes e travaria calculando em cima dos quase 95 mil clientes inteiros, uma amostra aleatória de 5 mil já estima o score sem travar.
| K | Silhueta | Inércia |
|---|---|---|
| 2 | 0,7430 | 207.275,6 |
| 3 | 0,4569 | 141.952,7 |
| 4 | 0,4900 | 94.963,3 |
| 5 | 0,4195 | 79.940,5 |
| 6 | 0,4387 | 65.668,3 |
| 7 | 0,4418 | 56.032,0 |
| 8 | 0,4495 | 50.353,9 |
Carregando dados reais...
K=2 disparado na frente (0,743, quase o dobro de qualquer outro K), e a inércia cai suave e continuamente sem cotovelo óbvio nenhum, dois sinais concordando: o corte mais "limpo" estatisticamente é dividir a base em dois grupos só. Só que eu já sei, do capítulo 3, o que esse corte provavelmente É: os 3,06% de clientes que compraram mais de uma vez contra os 96,94% de compra única. Estatisticamente é o K mais "puro", mas de negócio é quase a mesma informação que eu já tinha sem rodar clustering nenhum. Do K=3 pra frente a silhueta oscila meio sem padrão claro (entre 0,42 e 0,49), sem um segundo K que se destaque com folga. Vou levar essa tensão (K estatisticamente ótimo vs. segmentação rica o suficiente pra virar ação de negócio) pro capítulo 5, que é onde eu de fato escolho um K final, visualizo os grupos e interpreto o que cada um significa.
Fechando o capítulo
Os quatro modelos treinados e comparados contra baseline, com MLflow registrando cada tentativa. O achado mais importante não foi "qual modelo ganhou", foi a demonstração de que AUC boa não garante decisão boa: o Random Forest do cenário 1 tem a melhor métrica de ranking e zero utilidade prática no threshold padrão, uma armadilha real de quem só olha uma métrica e já sai comemorando. Frete é o cenário onde o modelo mais "compra" performance real (XGBoost, R² 0,64), nota de avaliação esbarra num teto baixo de informação disponível, e RFM levanta uma pergunta em aberto sobre o que significa "melhor" clusterização, que fica pro capítulo 5, junto com SHAP pra abrir a caixa-preta desses modelos e as conclusões finais do projeto.