Previsão de Demanda de Corridas
Um modelo espaço-temporal de demanda, um bug de avaliação encontrado em flagrante e o que mudou ao levá-lo para 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 número de linhas. Parecia tudo bem até os números não fecharem: a contagem bruta de corridas por zona-hora tinha média de 2.429 no treino, mas só 607 no teste, uma diferença de 4x, para o que deveria ser o mesmo padrão de demanda.
A causa: o volume de corridas é aproximadamente uniforme por dia, então uma divisão 80/20 por número de linhas não é uma divisão 80/20 por tempo. O treino acabou cobrindo 145 dias e o teste apenas 38, então a contagem bruta de uma zona-hora estava sendo comparada entre duas janelas de tamanhos completamente diferentes. Treinado e avaliado direto sobre essa soma bruta, o erro do modelo pareceria muito pior do que realmente era, por um motivo que nada tinha a ver com o modelo.
A correção: agregar em uma média diária de corridas para aquela divisão, em vez de uma soma bruta, que também é o número que importa para orientar motoristas (quantas corridas costumam acontecer nessa zona nessa hora). Depois da correção, as médias de treino e teste ficam em torno de 16 corridas por dia, e o baseline avalia em 3,24 de MAE e 5,09 de RMSE, cerca de um quinto da média e aproximadamente 22% da mediana do alvo.
O que a exploração encontrou
O volume de corridas atinge o pico às 18h e o mínimo às 5h, uma diferença de 6x, com sexta-feira como o dia mais movimentado e segunda-feira o mais calmo. Uma exceção marcante: 23 de janeiro de 2016 despencou para cerca de 1.600 corridas, contra 7.000 a 9.500 num dia normal, o que coincide exatamente com a tempestade de neve Jonas, um choque externo real de demanda que as features não têm como enxergar. A detecção de outliers por IQR nas coordenadas sinalizou de 4 a 6% das linhas, agressivo demais para dados geoespaciais. Uma checagem por limites administrativos do OpenStreetMap encontrou apenas 0,09% de outliers geográficos reais: falhas de GPS caindo perto de Sacramento e no meio do Atlântico. Na clusterização de zonas, a análise de silhueta tem pico em k=20, metade do k=40 que a metodologia original estimou no olho.
Validação do modelo, e o que não ajudou
O baseline XGBoost foi ajustado com busca aleatória sobre folds de validação cruzada temporal, e depois comparado com um LightGBM igualmente ajustado em um dataset mais rico em nível diário. O LightGBM venceu por uma margem sem muito significado: 4,626 de MAE contra 4,627 do XGBoost.
Uma extensão de pesquisa só no notebook testou então features de recência (a última contagem observada e uma média móvel recente) contra dois baselines de séries temporais, uma média móvel ingênua e Holt-Winters. As features de recência foram a maior alavanca testada, cerca de 20% de melhora no MAE. O serviço ainda não pode usá-las, porque não guarda estado e precisaria acompanhar as contagens recentes por zona e hora.
Uma segunda extensão adicionou clima diário e uma flag de feriado federal dos EUA, motivada pela queda causada pela tempestade Jonas. O clima piorou o modelo na divisão cronológica, MAE de 4,626 para 5,189, e o notebook diagnostica o motivo: a janela de teste (fim de maio a junho) não tem dias de neve, então a divisão não consegue recompensar o aprendizado de um efeito de neve. O clima fica em aberto em vez de rejeitado. A flag de feriado sozinha deu um ganho pequeno, de 4,626 para 4,580, mas baseado em um único feriado na janela de teste.
Do notebook à produção
Portar a lógica do notebook para um serviço real revelou problemas que só importam quando você realmente serve previsões. Primeiro, o notebook ajustava o KMeans das zonas de embarque nas coordenadas de treino e teste juntas. O pipeline de treino de produção ajusta só nos dados de treino, e depois atribui zonas ao teste com o modelo já ajustado.
Segundo, o notebook treinava com duração e distância médias das corridas como se fossem conhecidas de antemão, mas esses são resultados de corridas que ainda não aconteceram: um chamador real não consegue fornecê-los para uma janela de previsão futura. O pipeline de produção constrói, a partir dos dados de treino, um lookup (zona, hora) para médias históricas, então a API só precisa de uma zona (ou coordenadas brutas) e uma hora.
Duas outras mudanças dizem respeito aos dados de treino e às dependências. O serviço treina com uma linha por (zona, hora, data), dezenas de milhares de observações diárias reais em vez de algumas centenas de números pré-agregados, o que também elimina a diferença de janelas por trás do bug de avaliação. A distância usa haversine vetorizada em NumPy em vez de uma chamada geodésica linha a linha, uma diferença abaixo de 0,5% na escala de uma cidade, o que tira geopy, osmnx, geopandas, contextily e xgboost das dependências de execução do serviço.
Resultados
| Pipeline | MAE | RMSE |
|---|---|---|
| Baseline do notebook, XGBoost (taxa média por zona-hora) | 3,24 | 5,09 |
| Notebook, LightGBM ajustado (nível diário) | 4,63 | 6,64 |
| Serviço em produção (nível diário, sem vazamento) | 4,56 | 6,78 |
Todos os valores são corridas por dia. As duas últimas linhas medem o mesmo alvo em nível diário e são diretamente comparáveis. A primeira mede uma taxa pré-agregada, que é um alvo mais suave e mais fácil por construção, então não é comparável com as outras.
Exemplo de requisição
Respostas reais do artefato de modelo versionado, arredondadas para duas casas decimais na exibição. Não é preciso baixar nada do Kaggle para rodar isto.
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": 19.63}curl 'localhost:8000/rankings?hour=18&top_n=3'{"hour": 18, "rankings": [
{"pickup_zone": 0, "predicted_avg_daily_ride_count": 61.67},
{"pickup_zone": 15, "predicted_avg_daily_ride_count": 44.86},
{"pickup_zone": 19, "predicted_avg_daily_ride_count": 42.47}
]}Testes
55 testes cobrem o pipeline de dados, features e clusterização, além do serviço FastAPI, rodados com pytest. O CI executa ruff (lint e formatação), mypy e a suíte completa de testes a cada push e pull request, além de um job separado que constrói a imagem Docker, sobe o contêiner e faz um smoke test da API.
Limitações
- ▸Dados públicos servem como análogo estrutural, não como o dataset original: mesmo formato (coordenadas de embarque/desembarque com timestamp), outra cidade e sem campo de tarifa, então a duração da corrida substitui o valor da corrida em todo o projeto.
- ▸O serviço sem estado deixa precisão na mesa. Features de recência reduziram o MAE em cerca de 20% no notebook, mas usá-las exige um serviço que acompanhe as contagens recentes por zona e hora.
- ▸Clima e feriados não são usados na hora de servir. O resultado do clima é negativo, mas confundido, e usá-lo exigiria uma dependência de previsão ao vivo. O ganho do feriado exigiria uma entrada de data que a API não recebe, e um único feriado é evidência fraca para mudar a API.
- ▸Cerca de seis meses de dados de uma única cidade. O arquivo do Kaggle cobre de janeiro a junho de 2016, então padrões sazonais fora dessa janela não foram testados.
- ▸O retreinamento é um passo manual, e o modelo versionado foi treinado com os dados de 2016. A migração do treino para os dados mensais atuais da NYC TLC está em andamento: a ingestão e o treino em janela móvel existem, mas o modelo versionado ainda não foi substituído.