← Voltar pro projeto

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.

ModeloAUCF1PrecisãoRevocação
Baseline0,50000,00000,00000,0000
Regressão Logística0,60640,00311,00000,0015
Random Forest0,73880,00000,00000,0000
XGBoost0,72610,02680,40000,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).

ModeloR²MAE
Baseline-0,00010,9950
Regressão Linear0,12320,9228
Random Forest0,20280,8765
XGBoost0,18610,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).

ModeloR²MAE
Baseline-0,00018,6057
Regressão Linear0,53225,3800
Random Forest0,61784,5947
XGBoost0,63854,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.

KSilhuetaInércia
20,7430207.275,6
30,4569141.952,7
40,490094.963,3
50,419579.940,5
60,438765.668,3
70,441856.032,0
80,449550.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.