Estudo de caso

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

Dados do Kaggle
1,46M corridas
Limpeza + dedup
Clusterização de zonas
KMeans, k=20
Comparação de modelos
XGBoost vs LightGBM

Serviço em produção

Ajuste só no treino
sem vazamento
Lookup zona + hora
FastAPI
predict + rankings
Docker

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

PipelineMAERMSE
Baseline do notebook, XGBoost (taxa média por zona-hora)3,245,09
Notebook, LightGBM ajustado (nível diário)4,636,64
Serviço em produção (nível diário, sem vazamento)4,566,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.

Stack

PythonpandasNumPyscikit-learnLightGBMXGBoostFastAPIUvicornPydanticjoblibDockeruvruffmypypytest
Ver código-fonte no GitHub