Previsão de Demanda de Corridas
Um modelo espaço-temporal de demanda, um bug de avaliação flagrado em flagrante, e o que mudou ao colocá-lo em produção
O problema
Isso começou como um exercício para uma entrevista de data science em uma empresa de ride-hailing, construído sobre dados de corridas fornecidos por ela para uma cidade específica. Reutilizar publicamente o enunciado e o dataset exatos de uma empresa não é apropriado, então esta é uma reconstrução completa: a mesma metodologia, aplicada honestamente do início ao fim ao dataset público do Kaggle NYC Taxi Trip Duration (1,46 milhão de corridas). O dataset original incluía um campo de tarifa; este público não inclui, então a duração da corrida substitui o sinal por corrida ao longo de todo o projeto.
Arquitetura
Exploração
Serviço em produção
O bug de avaliação
A unidade de modelagem é (zona de embarque, hora), e a metodologia original usava uma divisão treino/teste simples de 80/20 por contagem de linhas. Isso parecia correto até os números não baterem: a contagem bruta de corridas por zona-hora tinha média de 1.214 no conjunto de treino, mas apenas 304 no conjunto de teste, uma diferença de 4x, para o que deveria ser o mesmo padrão de demanda subjacente.
A causa: o volume de corridas é razoavelmente uniforme por dia, então uma divisão de 80/20 por contagem de linhas não é uma divisão de 80/20 por tempo. O treino acabou cobrindo 145 dias, o teste apenas 38, então a contagem bruta de corridas de uma zona-hora estava sendo comparada entre duas janelas de tamanhos completamente diferentes. Treinado e avaliado diretamente sobre essa soma bruta, o erro do modelo pareceria muito pior do que realmente era, por um motivo que não tinha nada a ver com o modelo.
A correção: agregar em uma contagem média diária de corridas para aquela divisão, em vez de uma soma bruta, que também é o número que realmente importa para orientar motoristas (quantas corridas normalmente acontecem nessa zona naquele horário). Depois da correção, as médias de treino e teste ficam ambas por volta de 8 corridas por dia, e o baseline avalia para 1,72 de MAE e 2,67 de RMSE, cerca de um quinto da média.
O que a exploração encontrou
O volume de corridas atinge o pico às 18h e o vale às 5h, uma diferença de 6x, com sexta-feira mais movimentada e segunda-feira mais tranquila. Uma exceção brusca se destaca: 23 de janeiro de 2016 despencou para cerca de 1.600 corridas, vindo de um normal de 7.000 a 9.500, o que coincide exatamente com a tempestade de inverno Jonas, um choque real de demanda externo que o conjunto de features não tem como prever. Uma detecção simples de outliers por IQR nas coordenadas sinalizou de 4 a 6% das linhas, agressiva demais para dados geoespaciais. Trocar por uma checagem de limites administrativos do OpenStreetMap encontrou apenas 0,09% de geo-outliers genuínos: falhas de GPS caindo perto de Sacramento e sobre o Atlântico.
Do notebook para a produção
Portar a lógica do notebook para um serviço real revelou mais dois problemas que só importam quando você está de fato servindo predições. Primeiro, o notebook ajustava seu KMeans de zonas de embarque com as coordenadas de treino e teste combinadas. O pipeline de treino em produção o ajusta apenas com dados de treino, e depois atribui zonas ao conjunto de teste usando o modelo já ajustado.
Segundo, o notebook treinava usando a duração e a distância médias da corrida como se fossem conhecidas de antemão, mas esses são resultados de corridas que ainda não aconteceram: quem chama a API de verdade não pode fornecer isso para uma janela de predição futura. O pipeline de produção em vez disso constrói um lookup de (zona, hora) para a média histórica a partir dos dados de treino, então a API só precisa de uma zona (ou coordenadas brutas) e uma hora.
Com as duas correções em vigor, o pipeline de produção avalia para 2,10 de MAE e 3,24 de RMSE, pior que o 1,72 e 2,67 do notebook. Isso é esperado: as correções de vazamento removeram uma vantagem irreal que o baseline do notebook tinha. O número pior é o honesto.
Resultados
| Pipeline | MAE | RMSE |
|---|---|---|
| Baseline do notebook (com vazamento) | 1,72 | 2,67 |
| Serviço em produção (sem vazamento) | 2,10 | 3,24 |
Ambos são corridas por dia. O número de produção é o que reflete o que o serviço realmente consegue saber no momento da predição, então é o que importa.
Exemplo de requisição
Respostas reais do artefato de modelo já commitado. Não precisa baixar nada do Kaggle para rodar isso.
curl -X POST localhost:8000/predict \
-H "Content-Type: application/json" \
-d '{"pickup_zone": 5, "hour": 18}'{"pickup_zone": 5, "hour": 18, "predicted_avg_daily_ride_count": 7.41}curl 'localhost:8000/rankings?hour=18&top_n=3'{"hour": 18, "rankings": [
{"pickup_zone": 30, "predicted_avg_daily_ride_count": 30.08},
{"pickup_zone": 23, "predicted_avg_daily_ride_count": 28.87},
{"pickup_zone": 19, "predicted_avg_daily_ride_count": 28.64}
]}Testes
20 testes cobrem o pipeline de dados, features e clustering, além do serviço FastAPI, rodados com pytest. O CI roda ruff, mypy e a suíte de testes completa a cada push, além de um job separado que constrói a imagem Docker.
Limitações
- ▸Dados públicos servem como um análogo estrutural, não o dataset original: mesmo formato (coordenadas de embarque/desembarque com timestamp), uma cidade diferente, e sem campo de tarifa, então a duração da corrida substitui o valor da corrida ao longo de todo o projeto.
- ▸k=40 zonas de cluster é uma heurística herdada da metodologia original, não uma escolha validada. Uma análise adequada de elbow ou silhouette em uma amostra representativa seria o próximo passo.
- ▸Apenas modelo baseline. Nenhum ajuste de hiperparâmetros e nenhuma arquitetura alternativa (LightGBM, um modelo de série temporal) testada ainda.
- ▸Apenas retreino em lote. Não há um pipeline de retreino agendado ou CD; rodar o comando de treino é um passo manual por enquanto.