← Voltar pro projeto

Vazamento de Dado: o Jeito Mais Fácil de Enganar a Si Mesmo

Os capítulos 1 e 2 montaram o dataframe mestre e mostraram seis ângulos exploratórios. Agora eu preciso escolher, pra cada um dos quatro cenários definidos lá no capítulo 1, quais features fazem sentido usar. Notebook completo, executado, público no Colab. Esse notebook também parte do zero (recarrega os 9 CSVs e remonta o dataframe mestre com o mesmo código do capítulo 1) pra ficar autocontido.

Uma feature nova primeiro: distância cliente-vendedor

O capítulo 2 mostrou que estado do cliente correlaciona com tempo de entrega. Antes de entrar nos cenários, calculei uma distância de verdade, não só "mesmo estado ou não": latitude/longitude média por prefixo de CEP (olist_geolocation_dataset, mais de 1 milhão de linhas, por isso a média primeiro), e a fórmula de Haversine entre cliente e vendedor.

geo_media = geolocation.groupby('geolocation_zip_code_prefix')[['geolocation_lat', 'geolocation_lng']].mean().reset_index()

def haversine(lat1, lon1, lat2, lon2):
    """Distância em km entre dois pontos lat/lng, aproximando a Terra como esfera de raio 6371km."""
    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))

# força int64 dos dois lados da chave, alguns ambientes inferem tipos diferentes pro CEP e isso quebra o merge silenciosamente
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'])

groupby(...).mean() colapsa a tabela de geolocalização (mais de 1 milhão de linhas, várias coordenadas por prefixo de CEP) numa única lat/lng média por prefixo, senão o merge seguinte multiplicaria cada pedido por todas as coordenadas cadastradas naquele CEP. haversine é a fórmula clássica de distância entre dois pontos numa esfera: converte grau pra radiano, e a combinação de seno/cosseno dentro do arcsin dá a distância angular, multiplicada pelo raio da Terra (6.371 km) vira distância em km de verdade, não em linha reta no mapa plano, que distorceria bastante num país do tamanho do Brasil. O merge acontece duas vezes, uma pra trazer a coordenada do cliente, outra a do vendedor, cada uma renomeando as colunas genéricas (geolocation_lat/lng) pra um nome específico (cust_lat/sell_lat) antes de juntar, senão a segunda chamada de merge sobrescreveria as colunas que a primeira já tinha trazido.

Achei um bug de verdade nessa etapa antes de publicar: sem as três linhas de .astype('int64') logo no início, o merge entre customer_zip_code_prefix/seller_zip_code_prefix e a tabela de geolocalização perdeu 70% dos dados (só 30% de cobertura), porque o tipo da coluna de CEP não veio garantidamente igual dos dois lados em todo ambiente (um lado como int64, o outro como object ou float64, e o pandas simplesmente não casa as chaves quando os tipos divergem, sem lançar erro nenhum). Forcei int64 explícito nos três antes do merge, e a cobertura voltou pro esperado: só 581 pedidos de 118.310 ficaram sem distância (99,5% de cobertura). A distância média cliente-vendedor no Brasil inteiro é 597 km, com máximo de 8.678 km, uma prova em número de como o país é gigante.

Prova em imagem também: abaixo, uma amostra de 500 pedidos, cliente posicionado no plano (longitude e latitude) e a distância até o vendedor como altura. Gira e repara que os picos de distância não seguem um padrão simples de "quanto mais ao norte, mais longe", eles aparecem espalhados por latitudes bem diferentes, sinal de que a distância depende mais de qual vendedor específico atendeu aquele pedido do que só da região do cliente.

Carregando dados reais...

Cenário 1: Atraso na entrega, e a demonstração de vazamento de dado

Candidatas: distance_km, product_weight_g, price, freight_value, tempo de aprovação (approval_hours), mês da compra. O alvo atrasado é definido comparando order_delivered_customer_date com order_estimated_delivery_date. E aqui mora a armadilha clássica de todo curso de ML: se eu incluir atraso_dias (a própria diferença entre essas duas datas) como feature, o modelo não aprende a prever atraso, ele lê a resposta direto da prova.

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

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)

approval_hours mede quanto tempo a Olist levou pra aprovar o pagamento depois da compra, em horas (não em dias, porque a maioria aprova em poucas horas, e um dt.days arredondaria isso pra zero quase sempre). month extrai só o mês da compra, a feature de sazonalidade. atraso_dias é a diferença entre entrega real e prometida (negativo é adiantado, positivo é atraso de verdade), e atrasado vira o alvo binário. drop_duplicates(subset='order_id') volta o dataframe mestre pra uma linha por pedido (ele tinha uma linha por item, lembra do capítulo 1), senão pedidos com muitos itens pesariam mais no treino só por terem mais linhas, não por atrasarem mais.

Saída: 95.968 pedidos usados, taxa de atraso de 6,76%.

Com o dataframe pronto, treinei dois RandomForest, mesma seed, mesma divisão treino/teste, mudando só a lista de features:

features_honestas = ['distance_km', 'product_weight_g', 'price', 'freight_value', 'approval_hours', 'month']
features_vazadas = features_honestas + ['atraso_dias']

X = delay_df[features_honestas]
y = delay_df['atrasado']
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)

modelo_honesto = RandomForestClassifier(n_estimators=100, max_depth=8, random_state=42)
modelo_honesto.fit(Xtr, ytr)
acc_honesto = accuracy_score(yte, modelo_honesto.predict(Xte))
auc_honesto = roc_auc_score(yte, modelo_honesto.predict_proba(Xte)[:, 1])

Xl = delay_df[features_vazadas]
Xltr, Xlte, yltr, ylte = train_test_split(Xl, y, test_size=0.2, random_state=42, stratify=y)
modelo_vazado = RandomForestClassifier(n_estimators=100, max_depth=8, random_state=42)
modelo_vazado.fit(Xltr, yltr)
acc_vazado = accuracy_score(ylte, modelo_vazado.predict(Xlte))
auc_vazado = roc_auc_score(ylte, modelo_vazado.predict_proba(Xlte)[:, 1])

stratify=y garante que a proporção de atraso (6,76%) fique igual em treino e teste, importante com um desbalanceamento desse tamanho. Os dois blocos são idênticos em estrutura, RandomForestClassifier(n_estimators=100, max_depth=8, random_state=42) nos dois, mesmo random_state=42 no split dos dois. A única diferença real entre os dois treinos inteiros é uma única coluna extra na segunda lista de features: atraso_dias.

Carregando dados reais...

O modelo honesto chega em AUC 0,7388. O modelo vazado chega em AUC 1,0, perfeito, porque atraso_dias > 0 praticamente já É a definição do alvo. Um detalhe que vale destacar: a acurácia do modelo honesto sozinha (93,24%) parece ótima à primeira vista, mas como só 6,76% dos pedidos atrasam, um modelo bobo que sempre chuta "no prazo" já acerta 93,24% sem aprender nada. Acurácia sozinha engana quando as classes são desbalanceadas desse jeito, AUC é a métrica que conta a história real aqui.

Repara também a importância de feature do modelo honesto:

importancias = sorted(zip(features_honestas, modelo_honesto.feature_importances_), key=lambda x: -x[1])

zip empareja cada nome de feature com a importância correspondente (mesma ordem em que features_honestas foi passada pro treino), e sorted(..., key=lambda x: -x[1]) ordena do maior peso pro menor (o sinal de menos inverte a ordem crescente padrão).

Carregando dados reais...

month lidera disparado, com 0,36 de importância, praticamente o dobro da segunda colocada (distance_km, 0,18). Isso conecta direto com o achado do capítulo 2: novembro (Black Friday) sobrecarrega a logística de um jeito que nem distância física consegue explicar sozinha. Sazonalidade importa mais pra prever atraso do que o quão longe o vendedor está do cliente.

Cenário 2: Nota da avaliação

Candidatas: atraso_dias, tempo de entrega, preço, frete, número de parcelas. Como review_score é uma escala ordinal bimodal (o capítulo 2 já mostrou isso), usei informação mútua em vez de correlação de Pearson simples:

m['delivery_days'] = (m['order_delivered_customer_date'] - m['order_purchase_timestamp']).dt.days

s2 = (
    m.drop_duplicates(subset='order_id')
    [['order_id', 'atraso_dias', 'delivery_days', 'price', 'freight_value', 'payment_installments', 'review_score']]
    .dropna()
)

X2 = s2[['atraso_dias', 'delivery_days', 'price', 'freight_value', 'payment_installments']]
y2 = s2['review_score']
mi = mutual_info_classif(X2, y2, random_state=42)

delivery_days é o tempo real de entrega (compra até chegada), diferente de atraso_dias (que compara contra a data prometida, não contra a data de compra). De novo drop_duplicates(subset='order_id') volta pra uma linha por pedido. X2 reúne as cinco candidatas, y2 é a nota (review_score, o alvo categórico ordinal). mutual_info_classif mede quanta informação cada feature contínua carrega sobre um alvo categórico mesmo quando a relação não é linear, diferente da correlação de Pearson que já usei no capítulo 2, que assume relação linear, e uma escala bimodal como nota de review não se comporta assim.

FeatureInformação mútua
atraso_dias0,0684
delivery_days0,0571
freight_value0,0077
price0,0074
payment_installments0,0026

atraso_dias e delivery_days dominam, quase 10 vezes mais informativas que preço ou frete (95.829 pedidos usados). Confirma com uma técnica diferente exatamente o que a correlação do capítulo 2 já tinha apontado.

Cenário 3: Valor do frete

Candidatas: peso e as três dimensões do produto. Correlação de Pearson simples já resolve aqui:

s3 = (
    m.drop_duplicates(subset='order_id')
    [['order_id', 'freight_value', 'product_weight_g', 'product_length_cm', 'product_height_cm', 'product_width_cm']]
    .dropna()
)

correlacao_frete = s3[['freight_value', 'product_weight_g', 'product_length_cm', 'product_height_cm', 'product_width_cm']].corr()['freight_value'].sort_values(ascending=False)

.corr() calcula a matriz de correlação inteira entre as cinco colunas, e ['freight_value'] pega só a coluna referente ao frete: a correlação de cada variável com ela mesma (sempre 1,0, por isso freight_value não aparece na tabela abaixo) e com as outras quatro.

Saída: 98.650 pedidos usados.

FeatureCorrelação com freight_value
product_weight_g0,615
product_height_cm0,393
product_width_cm0,331
product_length_cm0,317

Peso domina moderadamente sobre qualquer dimensão isolada, o que faz sentido físico: transportadora cobra por peso volumétrico, mas peso real puxa mais a conta que qualquer lado da caixa sozinho.

Cenário 4: Segmentação de clientes (RFM)

Aqui não é seleção de feature no sentido supervisionado, é a engenharia das três variáveis que vão alimentar o clustering do capítulo 5: Recência, Frequência e Valor monetário por cliente.

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()

data_max é a data da compra mais recente no dataset inteiro, o "hoje" que a Recência usa como referência (não dá pra usar a data real de hoje, o dataset é de 2018, isso infla a recência de todo mundo igualmente e destrói a comparação relativa entre clientes). orders_validos descarta pedidos cancelados antes de qualquer agregação, cliente que cancelou não deveria contar como "cliente ativo" pro RFM. pagamento_por_pedido soma o valor pago, que pode vir em várias parcelas registradas como linhas separadas (a mesma multiplicação de linha que já vi no merge do capítulo 1), antes de juntar de volta com os pedidos. Com isso pronto, o groupby('customer_unique_id') calcula as três métricas de uma vez: recencia_dias (dias desde a última compra até data_max), frequencia (pedidos únicos) e valor_monetario (soma paga).

95.560 clientes únicos. E o número que mais chamou atenção: só 2.924 clientes (3,06% do total) fizeram mais de um pedido. A frequência média é 1,034, ou seja, a marketplace inteira é dominada por compra única. Isso muda completamente como pensar em segmentação no capítulo 5: não vai ter "cliente fiel" como categoria grande, o RFM aqui provavelmente separa mais por valor gasto e recência do que por frequência de compra.

Fechando o capítulo

Distância cliente-vendedor calculada (e um bug de merge real corrigido no processo), vazamento de dado demonstrado na prática (a diferença entre AUC 0,74 honesto e AUC 1,0 vazado é a lição mais concreta que dá pra dar sobre o assunto), e as features de cada cenário definidas com técnica apropriada pro tipo de problema. Sazonalidade se provou mais importante que distância pro atraso, atraso domina a nota de avaliação, peso domina o frete, e a base de clientes é majoritariamente de compra única. No próximo capítulo eu treino os modelos de verdade pra cada cenário, com tracking de experimento e comparação de métrica.